/* 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 "ScrollSnap.h" #include "FrameMetrics.h" #include "mozilla/ScrollContainerFrame.h" #include "mozilla/ScrollSnapInfo.h" #include "mozilla/ScrollSnapTargetId.h" #include "mozilla/ServoStyleConsts.h" #include "mozilla/StaticPrefs_layout.h" #include "mozilla/dom/Document.h" #include "nsContentUtils.h" #include "nsIFrame.h" #include "nsLayoutUtils.h" #include "nsPresContext.h" #include "nsString.h" #include "nsTArray.h" mozilla::LazyLogModule sApzScrollSnapLog("apz.scrollsnap"); #define SCROLL_SNAP_LOG(...) \ MOZ_LOG(sApzScrollSnapLog, LogLevel::Debug, (__VA_ARGS__)) namespace mozilla { /** * Keeps track of the current best edge to snap to. The criteria for * adding an edge depends on the scrolling unit. */ class CalcSnapPoints final { using SnapTarget = ScrollSnapInfo::SnapTarget; public: CalcSnapPoints(ScrollUnit aUnit, ScrollSnapFlags aSnapFlags, const nsPoint& aDestination, const nsPoint& aStartPos, const StyleScrollSnapStrictness& aXStrictness, const StyleScrollSnapStrictness& aYStrictness); struct SnapPosition : public SnapTarget { SnapPosition(const SnapTarget& aSnapTarget, nscoord aPosition, nscoord aDistanceOnOtherAxis) : SnapTarget(aSnapTarget), mPosition(aPosition), mDistanceOnOtherAxis(aDistanceOnOtherAxis) {} nscoord mPosition; // The distance from the scroll destination to this snap position on the // other axis. This value is used if there are multiple SnapPositions on // this axis, but the positions on the other axis are different. nscoord mDistanceOnOtherAxis; }; void AddHorizontalEdge(const SnapTarget& aTarget); void AddVerticalEdge(const SnapTarget& aTarget); struct CandidateTracker { // keeps track of the position of the current second best edge on the // opposite side of the best edge on this axis. // We use NSCoordSaturatingSubtract to calculate the distance between a // given position and this second best edge position so that it can be an // uninitialized value as the maximum possible value, because the first // distance calculation would always be nscoord_MAX. nscoord mSecondBestEdge = nscoord_MAX; // Assuming in most cases there's no multiple coincide snap points. AutoTArray mTargetIds; // keeps track of the positions of the current best edge on this axis. // NOTE: Each SnapPosition.mPosition points the same snap position on this // axis but other member variables of SnapPosition may have different // values. AutoTArray mBestEdges; bool EdgeFound() const { return !mBestEdges.IsEmpty(); } }; void AddEdge(const SnapPosition& aEdge, nscoord aDestination, nscoord aStartPos, nscoord aScrollingDirection, CandidateTracker* aCandidateTracker); SnapDestination GetBestEdge(const nsSize& aSnapportSize) const; nsPoint GetDefaultSnapPoint() const; nscoord XDistanceBetweenBestAndSecondEdge() const { return std::abs(NSCoordSaturatingSubtract( mTrackerOnX.mSecondBestEdge, mTrackerOnX.EdgeFound() ? mTrackerOnX.mBestEdges[0].mPosition : mDestination.x, nscoord_MAX)); } nscoord YDistanceBetweenBestAndSecondEdge() const { return std::abs(NSCoordSaturatingSubtract( mTrackerOnY.mSecondBestEdge, mTrackerOnY.EdgeFound() ? mTrackerOnY.mBestEdges[0].mPosition : mDestination.y, nscoord_MAX)); } const nsPoint& Destination() const { return mDestination; } protected: ScrollUnit mUnit; ScrollSnapFlags mSnapFlags; nsPoint mDestination; // gives the position after scrolling but before // snapping nsPoint mStartPos; // gives the position before scrolling nsIntPoint mScrollingDirection; // always -1, 0, or 1 StyleScrollSnapStrictness mStrictnessX; StyleScrollSnapStrictness mStrictnessY; CandidateTracker mTrackerOnX; CandidateTracker mTrackerOnY; }; CalcSnapPoints::CalcSnapPoints(ScrollUnit aUnit, ScrollSnapFlags aSnapFlags, const nsPoint& aDestination, const nsPoint& aStartPos, const StyleScrollSnapStrictness& aXStrictness, const StyleScrollSnapStrictness& aYStrictness) : mUnit(aUnit), mSnapFlags(aSnapFlags), mDestination(aDestination), mStartPos(aStartPos), mStrictnessX(aXStrictness), mStrictnessY(aYStrictness) { MOZ_ASSERT(aSnapFlags != ScrollSnapFlags::Disabled); nsPoint direction = aDestination - aStartPos; mScrollingDirection = nsIntPoint(0, 0); if (direction.x < 0) { mScrollingDirection.x = -1; } if (direction.x > 0) { mScrollingDirection.x = 1; } if (direction.y < 0) { mScrollingDirection.y = -1; } if (direction.y > 0) { mScrollingDirection.y = 1; } } nsPoint CalcSnapPoints::GetDefaultSnapPoint() const { MOZ_ASSERT(mSnapFlags != ScrollSnapFlags::Disabled); nsPoint defaultPoint = mDestination; if ((mSnapFlags & ScrollSnapFlags::IntendedDirection) == ScrollSnapFlags::IntendedDirection) { if (mStrictnessX != StyleScrollSnapStrictness::Proximity) { defaultPoint.x = mStartPos.x; } if (mStrictnessY != StyleScrollSnapStrictness::Proximity) { defaultPoint.y = mStartPos.y; } } return defaultPoint; } SnapDestination CalcSnapPoints::GetBestEdge(const nsSize& aSnapportSize) const { if (mTrackerOnX.EdgeFound() && mTrackerOnY.EdgeFound()) { nsPoint bestCandidate(mTrackerOnX.mBestEdges[0].mPosition, mTrackerOnY.mBestEdges[0].mPosition); nsRect snappedPort = nsRect(bestCandidate, aSnapportSize); // If we've found the candidates on both axes, it's possible some of // candidates will be outside of the snapport if we snap to the point // (mTrackerOnX.mBestEdges[0].mPosition, // mTrackerOnY.mBestEdges[0].mPosition). So we need to get the intersection // of the snap area of each snap target element on each axis and the // snapport to tell whether it's outside of the snapport or not. // // Also if at least either one of the elements will be outside of the // snapport if we snap to (mTrackerOnX.mBestEdges[0].mPosition, // mTrackerOnY.mBestEdges[0].mPosition). We need to choose one of // combinations of the candidates which is closest to the destination. // // So here we iterate over mTrackerOnX and mTrackerOnY just once // respectively for both purposes to avoid iterating over them again and // again. // // NOTE: Ideally we have to iterate over every possible combinations of // (mTrackerOnX.mBestEdges[i].mSnapPoint.mY, // mTrackerOnY.mBestEdges[j].mSnapPoint.mX) and tell whether the given // combination will be visible in the snapport or not (maybe we should // choose the one that the visible area, i.e., the intersection area of // the snap target elements and the snapport, is the largest one rather than // the closest one?). But it will be inefficient, so here we will not // iterate all the combinations, we just iterate all the snap target // elements in each axis respectively. AutoTArray visibleTargetIdsOnX; nscoord minimumDistanceOnY = nscoord_MAX; size_t minimumXIndex = 0; AutoTArray minimumDistanceTargetIdsOnX; for (size_t i = 0; i < mTrackerOnX.mBestEdges.Length(); i++) { const auto& targetX = mTrackerOnX.mBestEdges[i]; if (targetX.mSnapArea.Intersects(snappedPort)) { visibleTargetIdsOnX.AppendElement(targetX.mTargetId); } if (targetX.mDistanceOnOtherAxis < minimumDistanceOnY) { minimumDistanceOnY = targetX.mDistanceOnOtherAxis; minimumXIndex = i; minimumDistanceTargetIdsOnX = AutoTArray{targetX.mTargetId}; } else if (minimumDistanceOnY != nscoord_MAX && targetX.mDistanceOnOtherAxis == minimumDistanceOnY) { minimumDistanceTargetIdsOnX.AppendElement(targetX.mTargetId); } } AutoTArray visibleTargetIdsOnY; nscoord minimumDistanceOnX = nscoord_MAX; size_t minimumYIndex = 0; AutoTArray minimumDistanceTargetIdsOnY; for (size_t i = 0; i < mTrackerOnY.mBestEdges.Length(); i++) { const auto& targetY = mTrackerOnY.mBestEdges[i]; if (targetY.mSnapArea.Intersects(snappedPort)) { visibleTargetIdsOnY.AppendElement(targetY.mTargetId); } if (targetY.mDistanceOnOtherAxis < minimumDistanceOnX) { minimumDistanceOnX = targetY.mDistanceOnOtherAxis; minimumYIndex = i; minimumDistanceTargetIdsOnY = AutoTArray{targetY.mTargetId}; } else if (minimumDistanceOnX != nscoord_MAX && targetY.mDistanceOnOtherAxis == minimumDistanceOnX) { minimumDistanceTargetIdsOnY.AppendElement(targetY.mTargetId); } } // If we have the target ids on both axes, it means the target elements // (ids) specifying the best edge on X axis and the target elements // specifying the best edge on Y axis are visible if we snap to the best // edge. Thus they are valid snap positions. if (!visibleTargetIdsOnX.IsEmpty() && !visibleTargetIdsOnY.IsEmpty()) { return SnapDestination{ bestCandidate, ScrollSnapTargetIds{visibleTargetIdsOnX, visibleTargetIdsOnY}}; } // Now we've already known that snapping to // (mTrackerOnX.mBestEdges[0].mPosition, // mTrackerOnY.mBestEdges[0].mPosition) will make all candidates of // mTrackerX or mTrackerY (or both) outside of the snapport. We need to // choose another combination where candidates of both mTrackerX/Y are // inside the snapport. // There are three possibilities; // 1) There's no candidate on X axis in mTrackerOnY (that means // each candidate's scroll-snap-align is `none` on X axis), but there's // any candidate in mTrackerOnX, the closest candidates of mTrackerOnX // should be used. // 2) There's no candidate on Y axis in mTrackerOnX (that means // each candidate's scroll-snap-align is `none` on Y axis), but there's // any candidate in mTrackerOnY, the closest candidates of mTrackerOnY // should be used. // 3) There are candidates on both axes. Choosing a combination such as // (mTrackerOnX.mBestEdges[i].mSnapPoint.mX, // mTrackerOnY.mBestEdges[i].mSnapPoint.mY) // would require us to iterate over the candidates again if the // combination position is outside the snapport, which we don't want to // do. Instead, we choose either one of the axis' candidates. if ((minimumDistanceOnX == nscoord_MAX) && minimumDistanceOnY != nscoord_MAX) { bestCandidate.y = *mTrackerOnX.mBestEdges[minimumXIndex].mSnapPoint.mY; return SnapDestination{bestCandidate, ScrollSnapTargetIds{minimumDistanceTargetIdsOnX, minimumDistanceTargetIdsOnX}}; } if (minimumDistanceOnX != nscoord_MAX && minimumDistanceOnY == nscoord_MAX) { bestCandidate.x = *mTrackerOnY.mBestEdges[minimumYIndex].mSnapPoint.mX; return SnapDestination{bestCandidate, ScrollSnapTargetIds{minimumDistanceTargetIdsOnY, minimumDistanceTargetIdsOnY}}; } if (minimumDistanceOnX != nscoord_MAX && minimumDistanceOnY != nscoord_MAX) { // If we've found candidates on both axes, choose the closest point either // on X axis or Y axis from the scroll destination. I.e. choose // `minimumXIndex` one or `minimumYIndex` one to make at least one of // snap target elements visible inside the snapport. // // For example, // [bestCandidate.x, mTrackerOnX.mBestEdges[minimumXIndex].mSnapPoint.mY] // is a candidate generated from a single element, thus snapping to the // point would definitely make the element visible inside the snapport. if (hypotf(NSCoordToFloat(mDestination.x - mTrackerOnX.mBestEdges[0].mPosition), NSCoordToFloat(minimumDistanceOnY)) < hypotf(NSCoordToFloat(minimumDistanceOnX), NSCoordToFloat(mDestination.y - mTrackerOnY.mBestEdges[0].mPosition))) { bestCandidate.y = *mTrackerOnX.mBestEdges[minimumXIndex].mSnapPoint.mY; } else { bestCandidate.x = *mTrackerOnY.mBestEdges[minimumYIndex].mSnapPoint.mX; } return SnapDestination{bestCandidate, ScrollSnapTargetIds{minimumDistanceTargetIdsOnX, minimumDistanceTargetIdsOnY}}; } MOZ_ASSERT_UNREACHABLE("There's at least one candidate on either axis"); // `minimumDistanceOnX == nscoord_MAX && minimumDistanceOnY == nscoord_MAX` // should not happen but we fall back for safety. } nsPoint defaultPoint = GetDefaultSnapPoint(); return SnapDestination{ nsPoint(mTrackerOnX.EdgeFound() ? mTrackerOnX.mBestEdges[0].mPosition // In the case of IntendedEndPosition (i.e. the destination // point is explicitely specied, e.g. scrollTo) use the // destination point if we didn't find any candidates. : defaultPoint.x, mTrackerOnY.EdgeFound() ? mTrackerOnY.mBestEdges[0].mPosition // Same as above X axis case, use the destination point if we // didn't find any candidates. : defaultPoint.y), ScrollSnapTargetIds{mTrackerOnX.mTargetIds, mTrackerOnY.mTargetIds}}; } void CalcSnapPoints::AddHorizontalEdge(const SnapTarget& aTarget) { MOZ_ASSERT(aTarget.mSnapPoint.mY); AddEdge(SnapPosition{aTarget, *aTarget.mSnapPoint.mY, aTarget.mSnapPoint.mX ? std::abs(mDestination.x - *aTarget.mSnapPoint.mX) : nscoord_MAX}, mDestination.y, mStartPos.y, mScrollingDirection.y, &mTrackerOnY); } void CalcSnapPoints::AddVerticalEdge(const SnapTarget& aTarget) { MOZ_ASSERT(aTarget.mSnapPoint.mX); AddEdge(SnapPosition{aTarget, *aTarget.mSnapPoint.mX, aTarget.mSnapPoint.mY ? std::abs(mDestination.y - *aTarget.mSnapPoint.mY) : nscoord_MAX}, mDestination.x, mStartPos.x, mScrollingDirection.x, &mTrackerOnX); } void CalcSnapPoints::AddEdge(const SnapPosition& aEdge, nscoord aDestination, nscoord aStartPos, nscoord aScrollingDirection, CandidateTracker* aCandidateTracker) { if (mSnapFlags & ScrollSnapFlags::IntendedDirection) { // In the case of intended direction, we only want to snap to points ahead // of the direction we are scrolling. if (aScrollingDirection == 0 || (aEdge.mPosition - aStartPos) * aScrollingDirection <= 0) { // The scroll direction is neutral - will not hit a snap point, or the // edge is not in the direction we are scrolling, skip it. return; } } if (!aCandidateTracker->EdgeFound()) { aCandidateTracker->mBestEdges = AutoTArray{aEdge}; aCandidateTracker->mTargetIds = AutoTArray{aEdge.mTargetId}; return; } auto isPreferredStopAlways = [&](const SnapPosition& aSnapPosition) -> bool { MOZ_ASSERT(mSnapFlags & ScrollSnapFlags::IntendedDirection); // In the case of intended direction scroll operations, `scroll-snap-stop: // always` snap points in between the start point and the scroll destination // are preferable preferable. In other words any `scroll-snap-stop: always` // snap points can be handled as if it's `scroll-snap-stop: normal`. return aSnapPosition.mScrollSnapStop == StyleScrollSnapStop::Always && std::abs(aSnapPosition.mPosition - aStartPos) < std::abs(aDestination - aStartPos); }; const bool isOnOppositeSide = ((aEdge.mPosition - aDestination) > 0) != ((aCandidateTracker->mBestEdges[0].mPosition - aDestination) > 0); const nscoord distanceFromStart = aEdge.mPosition - aStartPos; // A utility function to update the best and the second best edges in the // given conditions. // |aIsCloserThanBest| True if the current candidate is closer than the best // edge. // |aIsCloserThanSecond| True if the current candidate is closer than // the second best edge. const nscoord distanceFromDestination = aEdge.mPosition - aDestination; auto updateBestEdges = [&](bool aIsCloserThanBest, bool aIsCloserThanSecond) { if (aIsCloserThanBest) { if (mSnapFlags & ScrollSnapFlags::IntendedDirection && isPreferredStopAlways(aEdge)) { // In the case of intended direction scroll operations and the new best // candidate is `scroll-snap-stop: always` and if it's closer to the // start position than the destination, thus we won't use the second // best edge since even if the snap port of the best edge covers entire // snapport, the `scroll-snap-stop: always` snap point is preferred than // any points. // NOTE: We've already ignored snap points behind start points so that // we can use std::abs here in the comparison. // // For example, if there's a `scroll-snap-stop: always` in between the // start point and destination, no `snap-overflow` mechanism should // happen, if there's `scroll-snap-stop: always` further than the // destination, `snap-overflow` might happen something like below // diagram. // start always dest other always // |------------|---------|------| aCandidateTracker->mSecondBestEdge = aEdge.mPosition; } else if (isOnOppositeSide) { // Replace the second best edge with the current best edge only if the // new best edge (aEdge) is on the opposite side of the current best // edge. aCandidateTracker->mSecondBestEdge = aCandidateTracker->mBestEdges[0].mPosition; } aCandidateTracker->mBestEdges = AutoTArray{aEdge}; aCandidateTracker->mTargetIds = AutoTArray{aEdge.mTargetId}; } else { if (aEdge.mPosition == aCandidateTracker->mBestEdges[0].mPosition) { aCandidateTracker->mTargetIds.AppendElement(aEdge.mTargetId); aCandidateTracker->mBestEdges.AppendElement(aEdge); } if (aIsCloserThanSecond && isOnOppositeSide) { aCandidateTracker->mSecondBestEdge = aEdge.mPosition; } } }; bool isCandidateOfBest = false; bool isCandidateOfSecondBest = false; switch (mUnit) { case ScrollUnit::DEVICE_PIXELS: case ScrollUnit::LINES: case ScrollUnit::WHOLE: { isCandidateOfBest = std::abs(distanceFromDestination) < std::abs(aCandidateTracker->mBestEdges[0].mPosition - aDestination); isCandidateOfSecondBest = std::abs(distanceFromDestination) < std::abs(NSCoordSaturatingSubtract(aCandidateTracker->mSecondBestEdge, aDestination, nscoord_MAX)); break; } case ScrollUnit::PAGES: { // distance to the edge from the scrolling destination in the direction of // scrolling nscoord overshoot = distanceFromDestination * aScrollingDirection; // distance to the current best edge from the scrolling destination in the // direction of scrolling nscoord curOvershoot = (aCandidateTracker->mBestEdges[0].mPosition - aDestination) * aScrollingDirection; nscoord secondOvershoot = NSCoordSaturatingSubtract(aCandidateTracker->mSecondBestEdge, aDestination, nscoord_MAX) * aScrollingDirection; // edges between the current position and the scrolling destination are // favoured to preserve context if (overshoot < 0) { isCandidateOfBest = overshoot > curOvershoot || curOvershoot >= 0; isCandidateOfSecondBest = overshoot > secondOvershoot || secondOvershoot >= 0; } // if there are no edges between the current position and the scrolling // destination the closest edge beyond the destination is used if (overshoot > 0) { isCandidateOfBest = overshoot < curOvershoot; isCandidateOfSecondBest = overshoot < secondOvershoot; } } } if (mSnapFlags & ScrollSnapFlags::IntendedDirection) { if (isPreferredStopAlways(aEdge)) { // If the given position is `scroll-snap-stop: always` and if the position // is in between the start and the destination positions, update the best // position based on the distance from the __start__ point. isCandidateOfBest = std::abs(distanceFromStart) < std::abs(aCandidateTracker->mBestEdges[0].mPosition - aStartPos); } else if (isPreferredStopAlways(aCandidateTracker->mBestEdges[0])) { // If we've found a preferable `scroll-snap-stop:always` position as the // best, do not update it unless the given position is also // `scroll-snap-stop: always`. isCandidateOfBest = false; } } updateBestEdges(isCandidateOfBest, isCandidateOfSecondBest); } using SnapTarget = ScrollSnapInfo::SnapTarget; static void ProcessSnapPositions(CalcSnapPoints& aCalcSnapPoints, const ScrollSnapInfo& aSnapInfo) { aSnapInfo.ForEachValidTargetFor( aCalcSnapPoints.Destination(), [&](const SnapTarget& aTarget) -> bool { if (aTarget.mSnapPoint.mX && aSnapInfo.mScrollSnapStrictnessX != StyleScrollSnapStrictness::None) { aCalcSnapPoints.AddVerticalEdge(aTarget); } if (aTarget.mSnapPoint.mY && aSnapInfo.mScrollSnapStrictnessY != StyleScrollSnapStrictness::None) { aCalcSnapPoints.AddHorizontalEdge(aTarget); } return true; }); } static void ProcessSnapOverflowForAxis( CalcSnapPoints& aCalcSnapPoints, layers::ScrollDirection aScrollDirection, nscoord aClampedDestination, nscoord aSnapportSize, const nsTArray& aRanges) { auto addEdge = [&](nscoord aSnapPoint, const nsRect& aSnapArea) { if (aScrollDirection == layers::ScrollDirection::eHorizontal) { aCalcSnapPoints.AddVerticalEdge(ScrollSnapInfo::SnapTarget{ Some(aSnapPoint), Nothing(), aSnapArea, StyleScrollSnapStop::Normal, ScrollSnapTargetId::None}); } else { aCalcSnapPoints.AddHorizontalEdge(ScrollSnapInfo::SnapTarget{ Nothing(), Some(aSnapPoint), aSnapArea, StyleScrollSnapStop::Normal, ScrollSnapTargetId::None}); } }; for (const auto& range : aRanges) { if (range.IsValid(aClampedDestination, aSnapportSize)) { addEdge(range.FindNearestSnapPoint(aClampedDestination, aSnapportSize), range.mSnapArea); break; } } } static void ProcessSnapOverflow(CalcSnapPoints& aCalcSnapPoints, const ScrollSnapInfo& aSnapInfo, const nsRect& aScrollRange, const nsPoint& aDestination) { // If the distance between the first and the second candidate snap points // is larger than the snapport size and the snapport is covered by larger // elements, any points inside the covering area should be valid snap // points. // https://drafts.csswg.org/css-scroll-snap-1/#snap-overflow // NOTE: |aDestination| sometimes points outside of the scroll range, e.g. // by the APZC fling, so for the overflow checks we need to clamp it. nsPoint clampedDestination = aScrollRange.ClampPoint(aDestination); if (aCalcSnapPoints.XDistanceBetweenBestAndSecondEdge() > aSnapInfo.mSnapportSize.width) { ProcessSnapOverflowForAxis( aCalcSnapPoints, layers::ScrollDirection::eHorizontal, clampedDestination.x, aSnapInfo.mSnapportSize.width, aSnapInfo.mXRangeWiderThanSnapport); } if (aCalcSnapPoints.YDistanceBetweenBestAndSecondEdge() > aSnapInfo.mSnapportSize.height) { ProcessSnapOverflowForAxis( aCalcSnapPoints, layers::ScrollDirection::eVertical, clampedDestination.y, aSnapInfo.mSnapportSize.height, aSnapInfo.mYRangeWiderThanSnapport); } } Maybe ScrollSnapUtils::GetSnapPointForDestination( const ScrollSnapInfo& aSnapInfo, ScrollUnit aUnit, ScrollSnapFlags aSnapFlags, const nsRect& aScrollRange, const nsPoint& aStartPos, const nsPoint& aDestination) { if (aSnapInfo.mScrollSnapStrictnessY == StyleScrollSnapStrictness::None && aSnapInfo.mScrollSnapStrictnessX == StyleScrollSnapStrictness::None) { return Nothing(); } if (!aSnapInfo.HasSnapPositions()) { return Nothing(); } CalcSnapPoints calcSnapPoints(aUnit, aSnapFlags, aDestination, aStartPos, aSnapInfo.mScrollSnapStrictnessX, aSnapInfo.mScrollSnapStrictnessY); ProcessSnapPositions(calcSnapPoints, aSnapInfo); ProcessSnapOverflow(calcSnapPoints, aSnapInfo, aScrollRange, aDestination); bool snapped = false; auto finalPos = calcSnapPoints.GetBestEdge(aSnapInfo.mSnapportSize); // Check whether we will snap to the final position on the given axis or not, // and if we will not, reset the final position to the original position so // that even if we need to snap on an axis, but we don't need to on the other // axis, the returned final position can be used as a valid destination. auto checkSnapOnAxis = [&snapped](StyleScrollSnapStrictness aStrictness, nscoord aDestination, nscoord aSnapportSize, nscoord& aFinalPosition) { // We used 0.3 proximity threshold which is what WebKit uses. constexpr float proximityRatio = 0.3; if (aStrictness == StyleScrollSnapStrictness::None || (aStrictness == StyleScrollSnapStrictness::Proximity && std::abs(aDestination - aFinalPosition) > aSnapportSize * proximityRatio)) { aFinalPosition = aDestination; return; } snapped = true; }; checkSnapOnAxis(aSnapInfo.mScrollSnapStrictnessY, aDestination.y, aSnapInfo.mSnapportSize.height, finalPos.mPosition.y); checkSnapOnAxis(aSnapInfo.mScrollSnapStrictnessX, aDestination.x, aSnapInfo.mSnapportSize.width, finalPos.mPosition.x); return snapped ? Some(finalPos) : Nothing(); } ScrollSnapTargetId ScrollSnapUtils::GetTargetIdFor(const nsIFrame* aFrame) { MOZ_ASSERT(aFrame && aFrame->GetContent()); return ScrollSnapTargetId{reinterpret_cast(aFrame->GetContent())}; } static const nsIContent* ResolveSnapTargetToContent( const ScrollSnapTargetId& aId) { if (aId == ScrollSnapTargetId::None) { return nullptr; } return reinterpret_cast(aId); } static bool SnapTargetIsFlattenedTreeDescendantOf( const ScrollSnapTargetId& aPossibleDescendant, const ScrollSnapTargetId& aPossibleAncestor) { MOZ_ASSERT(aPossibleAncestor != ScrollSnapTargetId::None && aPossibleDescendant != ScrollSnapTargetId::None); return nsContentUtils::ContentIsFlattenedTreeDescendantOf( ResolveSnapTargetToContent(aPossibleDescendant), ResolveSnapTargetToContent(aPossibleAncestor)); } static std::pair, Maybe> GetCandidateInLastTargets( const ScrollSnapInfo& aSnapInfo, const nsPoint& aCurrentPosition, const UniquePtr& aLastSnapTargetIds, const nsIContent* aFocusedContent, const nsIContent* aTargetContent, const WritingMode aWM) { auto GetTargetId = [](const nsIContent* aContent) -> ScrollSnapTargetId { if (aContent && aContent->GetPrimaryFrame()) { return ScrollSnapUtils::GetTargetIdFor(aContent->GetPrimaryFrame()); } return ScrollSnapTargetId::None; }; ScrollSnapTargetId targetIdForFocusedContent = GetTargetId(aFocusedContent); ScrollSnapTargetId targetIdForTargetContent = GetTargetId(aTargetContent); const bool isVertical = aWM.IsVertical(); // Note: Below algorithm doesn't care about cases where the last snap point // was on an element larger than the snapport since it's not clear to us // what we should do for now. // https://github.com/w3c/csswg-drafts/issues/7438 // Build the inline and block candidate sets from the last snap targets, per // https://drafts.csswg.org/css-scroll-snap-1/#multiple-aligned-snap-areas AutoTArray inlineSet, blockSet; const ScrollSnapInfo::SnapTarget* focusedTarget = nullptr; const ScrollSnapInfo::SnapTarget* targetedTarget = nullptr; aSnapInfo.ForEachValidTargetFor( aCurrentPosition, [&](const SnapTarget& aTarget) -> bool { if (aTarget.mSnapPoint.I(aWM) && aSnapInfo.StrictnessInline(aWM) != StyleScrollSnapStrictness::None && aLastSnapTargetIds->IdsOnInline(aWM).Contains(aTarget.mTargetId)) { inlineSet.AppendElement(&aTarget); } if (aTarget.mSnapPoint.B(aWM) && aSnapInfo.StrictnessBlock(aWM) != StyleScrollSnapStrictness::None && aLastSnapTargetIds->IdsOnBlock(aWM).Contains(aTarget.mTargetId)) { blockSet.AppendElement(&aTarget); } if (aLastSnapTargetIds->Contains(aTarget.mTargetId)) { if (aTarget.mTargetId == targetIdForFocusedContent || (targetIdForFocusedContent != ScrollSnapTargetId::None && SnapTargetIsFlattenedTreeDescendantOf(targetIdForFocusedContent, aTarget.mTargetId))) { focusedTarget = &aTarget; } if (aTarget.mTargetId == targetIdForTargetContent) { targetedTarget = &aTarget; } } return true; }); if (MOZ_LOG_TEST(sApzScrollSnapLog, LogLevel::Debug)) { SCROLL_SNAP_LOG("All snap targets: %s", ToString(aSnapInfo.mSnapTargets).c_str()); SCROLL_SNAP_LOG("Inline snap targets: %s", ToString(inlineSet).c_str()); SCROLL_SNAP_LOG("Block snap targets: %s", ToString(blockSet).c_str()); } // Step 4.1: If the focused element is in a set, it's the only candidate. if (focusedTarget) { if (focusedTarget->mSnapPoint.I(aWM) && aSnapInfo.StrictnessInline(aWM) != StyleScrollSnapStrictness::None) { inlineSet = {focusedTarget}; } if (focusedTarget->mSnapPoint.B(aWM) && aSnapInfo.StrictnessBlock(aWM) != StyleScrollSnapStrictness::None) { blockSet = {focusedTarget}; } } // Step 4.2: If no focused element was found but the :target element is // in a set, it's the only candidate. if (!focusedTarget && targetedTarget) { if (targetedTarget->mSnapPoint.I(aWM) && aSnapInfo.StrictnessInline(aWM) != StyleScrollSnapStrictness::None) { inlineSet = {targetedTarget}; } if (targetedTarget->mSnapPoint.B(aWM) && aSnapInfo.StrictnessBlock(aWM) != StyleScrollSnapStrictness::None) { blockSet = {targetedTarget}; } } // Step 4.3: For each box in a set, remove any box from the set that is an // ancestor of that box. auto removeAncestors = [](AutoTArray& aSet) { if (aSet.Length() <= 1) { return; } AutoTArray result; for (const auto* candidate : aSet) { bool isAncestorOfAnotherInSet = false; for (const auto* other : aSet) { if (other == candidate) { continue; } if (SnapTargetIsFlattenedTreeDescendantOf(other->mTargetId, candidate->mTargetId)) { isAncestorOfAnotherInSet = true; break; } } if (!isAncestorOfAnotherInSet) { result.AppendElement(candidate); } } aSet = std::move(result); }; removeAncestors(inlineSet); removeAncestors(blockSet); // Step 5: If the inline and block sets overlap (share at least one element), // replace both with their intersection. If they are disjoint, the block axis // set takes precedence and is used for both axes. AutoTArray intersection; for (const auto* inlineTarget : inlineSet) { for (const auto* blockTarget : blockSet) { if (inlineTarget->mTargetId == blockTarget->mTargetId) { intersection.AppendElement(inlineTarget); break; } } } const auto& effective = !intersection.IsEmpty() ? intersection : !blockSet.IsEmpty() ? blockSet : inlineSet; // Select the first candidate from each set in tree order, using a // cross-axis visibility check to prefer targets whose snap area remains // visible at the combined snap position. Maybe x, y; const ScrollSnapInfo::SnapTarget* inlinePick{nullptr}; const ScrollSnapInfo::SnapTarget* blockPick{nullptr}; auto pickFromInline = [&]() { Maybe& inlineCoord = isVertical ? y : x; const Maybe& blockCoord = isVertical ? x : y; for (const auto* target : effective) { // When effective == blockSet, targets may not have an inline snap point. const auto& sp = target->mSnapPoint.I(aWM); if (!sp) { continue; } if (!blockCoord || target->mSnapArea.Intersects( nsRect(isVertical ? nsPoint(*blockCoord, *sp) : nsPoint(*sp, *blockCoord), aSnapInfo.mSnapportSize))) { inlineCoord = sp; inlinePick = target; return; } } }; auto pickFromBlock = [&]() { Maybe& blockCoord = isVertical ? x : y; const Maybe& inlineCoord = isVertical ? y : x; for (const auto* target : effective) { // When effective == inlineSet, targets may not have a block snap point. const auto& sp = target->mSnapPoint.B(aWM); if (!sp) { continue; } if (!inlineCoord || target->mSnapArea.Intersects( nsRect(isVertical ? nsPoint(*sp, *inlineCoord) : nsPoint(*inlineCoord, *sp), aSnapInfo.mSnapportSize))) { blockCoord = sp; blockPick = target; return; } } }; // Only pick snap positions for axes that have snapping enabled. if (aSnapInfo.StrictnessInline(aWM) != StyleScrollSnapStrictness::None) { pickFromInline(); if (inlinePick && MOZ_LOG_TEST(sApzScrollSnapLog, LogLevel::Debug)) { SCROLL_SNAP_LOG("Inline snap target pick: %s", ToString(*inlinePick).c_str()); } } if (aSnapInfo.StrictnessBlock(aWM) != StyleScrollSnapStrictness::None) { pickFromBlock(); if (blockPick && MOZ_LOG_TEST(sApzScrollSnapLog, LogLevel::Debug)) { SCROLL_SNAP_LOG("Block snap target pick: %s", ToString(*blockPick).c_str()); } } return {x, y}; } Maybe ScrollSnapUtils::GetSnapPointForResnap( const ScrollSnapInfo& aSnapInfo, const nsRect& aScrollRange, const nsPoint& aCurrentPosition, const UniquePtr& aLastSnapTargetIds, const nsIContent* aFocusedContent, const nsIContent* aTargetContent, const WritingMode aWritingMode) { if (!aLastSnapTargetIds) { return GetSnapPointForDestination(aSnapInfo, ScrollUnit::DEVICE_PIXELS, ScrollSnapFlags::IntendedEndPosition, aScrollRange, aCurrentPosition, aCurrentPosition); } auto [x, y] = GetCandidateInLastTargets(aSnapInfo, aCurrentPosition, aLastSnapTargetIds, aFocusedContent, aTargetContent, aWritingMode); if (!x && !y) { // In the worst case there's no longer valid snap points previously snapped, // try to find new valid snap points. return GetSnapPointForDestination(aSnapInfo, ScrollUnit::DEVICE_PIXELS, ScrollSnapFlags::IntendedEndPosition, aScrollRange, aCurrentPosition, aCurrentPosition); } // If there's no candidate on one of the axes in the last snap points, try // to find a new candidate. if (!x || !y) { nsPoint newPosition = nsPoint(x ? *x : aCurrentPosition.x, y ? *y : aCurrentPosition.y); CalcSnapPoints calcSnapPoints( ScrollUnit::DEVICE_PIXELS, ScrollSnapFlags::IntendedEndPosition, newPosition, newPosition, aSnapInfo.mScrollSnapStrictnessX, aSnapInfo.mScrollSnapStrictnessY); aSnapInfo.ForEachValidTargetFor( newPosition, [&, &x = x, &y = y](const SnapTarget& aTarget) -> bool { if (!x && aTarget.mSnapPoint.mX && aSnapInfo.mScrollSnapStrictnessX != StyleScrollSnapStrictness::None) { calcSnapPoints.AddVerticalEdge(aTarget); } if (!y && aTarget.mSnapPoint.mY && aSnapInfo.mScrollSnapStrictnessY != StyleScrollSnapStrictness::None) { calcSnapPoints.AddHorizontalEdge(aTarget); } return true; }); auto finalPos = calcSnapPoints.GetBestEdge(aSnapInfo.mSnapportSize); if (!x) { x = Some(finalPos.mPosition.x); } if (!y) { y = Some(finalPos.mPosition.y); } } SnapDestination snapTarget{nsPoint(*x, *y)}; // Collect snap points where the position is still same as the new snap // position. aSnapInfo.ForEachValidTargetFor( snapTarget.mPosition, [&, &x = x, &y = y](const SnapTarget& aTarget) -> bool { if (aTarget.mSnapPoint.mX && aSnapInfo.mScrollSnapStrictnessX != StyleScrollSnapStrictness::None && aTarget.mSnapPoint.mX == x && // Ignore snap points outside of the snapport to optimize the next // re-snap iteration. aTarget.mSnapArea.Intersects( nsRect(nsPoint(*x, *y), aSnapInfo.mSnapportSize))) { snapTarget.mTargetIds.mIdsOnX.AppendElement(aTarget.mTargetId); } if (aTarget.mSnapPoint.mY && aSnapInfo.mScrollSnapStrictnessY != StyleScrollSnapStrictness::None && aTarget.mSnapPoint.mY == y && // Likewise for the Y axis. aTarget.mSnapArea.Intersects( nsRect(nsPoint(*x, *y), aSnapInfo.mSnapportSize))) { snapTarget.mTargetIds.mIdsOnY.AppendElement(aTarget.mTargetId); } return true; }); return Some(snapTarget); } void ScrollSnapUtils::PostPendingResnapIfNeededFor(nsIFrame* aFrame) { MOZ_ASSERT(aFrame); ScrollSnapTargetId id = GetTargetIdFor(aFrame); if (id == ScrollSnapTargetId::None) { return; } ScrollContainerFrame* sf = nsLayoutUtils::GetNearestScrollContainerFrame( aFrame, nsLayoutUtils::SCROLLABLE_SAME_DOC | nsLayoutUtils::SCROLLABLE_INCLUDE_HIDDEN); if (!sf) { return; } sf->PostPendingResnapIfNeeded(aFrame); nsIContent* focusedContent = aFrame->PresContext()->Document()->GetUnretargetedFocusedContent( dom::Document::IncludeChromeOnly::No); // If the focused content is a descendant of |aFrame|, ancestor scroll // containers may also need to re-snap since |sf| or other ancestors may be // registered as their snap target. if (!focusedContent || !nsContentUtils::ContentIsFlattenedTreeDescendantOf( focusedContent, aFrame->GetContent())) { return; } AutoTArray targets = {sf}; for (nsIFrame* f = sf->GetParent(); f; f = f->GetParent()) { if (ScrollContainerFrame* ancestorSf = do_QueryFrame(f)) { for (nsIFrame* target : targets) { ancestorSf->PostPendingResnapIfNeeded(target); } targets.ClearAndRetainStorage(); } targets.AppendElement(f); } } void ScrollSnapUtils::PostPendingResnapFor(nsIFrame* aFrame) { if (ScrollContainerFrame* sf = nsLayoutUtils::GetNearestScrollContainerFrame( aFrame, nsLayoutUtils::SCROLLABLE_SAME_DOC | nsLayoutUtils::SCROLLABLE_INCLUDE_HIDDEN)) { sf->PostPendingResnap(); } } bool ScrollSnapUtils::NeedsToRespectTargetWritingMode( const nsSize& aSnapAreaSize, const nsSize& aSnapportSize) { // Use the writing-mode on the target element if the snap area is larger than // the snapport. // https://drafts.csswg.org/css-scroll-snap/#snap-scope // // It's unclear `larger` means that the size is larger than only on the target // axis. If it doesn't, it will pick the same axis in the case where only one // axis is larger. For example, if an element size is (200 x 10) and the // snapport size is (100 x 100) and if the element's writing mode is different // from the scroller's writing mode, then `scroll-snap-align: start start` // will be conflict. return aSnapAreaSize.width > aSnapportSize.width || aSnapAreaSize.height > aSnapportSize.height; } static nsRect InflateByScrollMargin(const nsRect& aTargetRect, const nsMargin& aScrollMargin, const nsRect& aScrolledRect) { // Inflate the rect by scroll-margin. nsRect result = aTargetRect; result.Inflate(aScrollMargin); // But don't be beyond the limit boundary. return result.Intersect(aScrolledRect); } nsRect ScrollSnapUtils::GetSnapAreaFor(const nsIFrame* aFrame, const nsIFrame* aScrolledFrame, const nsRect& aScrolledRect) { nsRect targetRect = nsLayoutUtils::TransformFrameRectToAncestor( aFrame, aFrame->GetRectRelativeToSelf(), aScrolledFrame); // The snap area contains scroll-margin values. // https://drafts.csswg.org/css-scroll-snap-1/#scroll-snap-area nsMargin scrollMargin = aFrame->StyleMargin()->GetScrollMargin(); return InflateByScrollMargin(targetRect, scrollMargin, aScrolledRect); } } // namespace mozilla