/* -*- 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 "ScrollSnap.h" #include "FrameMetrics.h" #include "mozilla/ServoStyleConsts.h" #include "nsIFrame.h" #include "nsIScrollableFrame.h" #include "nsLayoutUtils.h" #include "nsPresContext.h" #include "nsTArray.h" #include "mozilla/StaticPrefs_layout.h" namespace mozilla { using layers::ScrollSnapInfo; /** * Keeps track of the current best edge to snap to. The criteria for * adding an edge depends on the scrolling unit. */ class CalcSnapPoints final { public: CalcSnapPoints(ScrollUnit aUnit, ScrollSnapFlags aSnapFlags, const nsPoint& aDestination, const nsPoint& aStartPos); struct SnapPosition { SnapPosition() = default; SnapPosition(nscoord aPosition, StyleScrollSnapStop aScrollSnapStop, ScrollSnapTargetId aTargetId) : mPosition(aPosition), mScrollSnapStop(aScrollSnapStop), mTargetId(aTargetId) {} nscoord mPosition; StyleScrollSnapStop mScrollSnapStop; ScrollSnapTargetId mTargetId; }; void AddHorizontalEdge(const SnapPosition& aEdge); void AddVerticalEdge(const SnapPosition& aEdge); struct CandidateTracker { explicit CandidateTracker(nscoord aDestination) : mBestEdge(SnapPosition{aDestination, StyleScrollSnapStop::Normal, ScrollSnapTargetId::None}) { // 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. mSecondBestEdge = SnapPosition{nscoord_MAX, StyleScrollSnapStop::Normal, ScrollSnapTargetId::None}; mEdgeFound = false; } // keeps track of the position of the current best edge on this axis. SnapPosition mBestEdge; // keeps track of the position of the current second best edge on the // opposite side of the best edge on this axis. SnapPosition mSecondBestEdge; bool mEdgeFound; // true if mBestEdge is storing a valid edge. // Assuming in most cases there's no multiple coincide snap points. AutoTArray mTargetIds; }; void AddEdge(const SnapPosition& aEdge, nscoord aDestination, nscoord aStartPos, nscoord aScrollingDirection, CandidateTracker* aCandidateTracker); SnapTarget GetBestEdge() const; nscoord XDistanceBetweenBestAndSecondEdge() const { return std::abs(NSCoordSaturatingSubtract( mTrackerOnX.mSecondBestEdge.mPosition, mTrackerOnX.mBestEdge.mPosition, nscoord_MAX)); } nscoord YDistanceBetweenBestAndSecondEdge() const { return std::abs(NSCoordSaturatingSubtract( mTrackerOnY.mSecondBestEdge.mPosition, mTrackerOnY.mBestEdge.mPosition, 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 CandidateTracker mTrackerOnX; CandidateTracker mTrackerOnY; }; CalcSnapPoints::CalcSnapPoints(ScrollUnit aUnit, ScrollSnapFlags aSnapFlags, const nsPoint& aDestination, const nsPoint& aStartPos) : mUnit(aUnit), mSnapFlags(aSnapFlags), mDestination(aDestination), mStartPos(aStartPos), mTrackerOnX(aDestination.x), mTrackerOnY(aDestination.y) { 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; } } SnapTarget CalcSnapPoints::GetBestEdge() const { return SnapTarget{ nsPoint( mTrackerOnX.mEdgeFound ? mTrackerOnX.mBestEdge.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. : !(mSnapFlags & ScrollSnapFlags::IntendedDirection) ? mDestination.x : mStartPos.x, mTrackerOnY.mEdgeFound ? mTrackerOnY.mBestEdge.mPosition // Same as above X axis case, use the destination point if we didn't // find any candidates. : !(mSnapFlags & ScrollSnapFlags::IntendedDirection) ? mDestination.y : mStartPos.y), ScrollSnapTargetIds{mTrackerOnX.mTargetIds, mTrackerOnY.mTargetIds}}; } void CalcSnapPoints::AddHorizontalEdge(const SnapPosition& aEdge) { AddEdge(aEdge, mDestination.y, mStartPos.y, mScrollingDirection.y, &mTrackerOnY); } void CalcSnapPoints::AddVerticalEdge(const SnapPosition& aEdge) { AddEdge(aEdge, 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->mEdgeFound) { aCandidateTracker->mBestEdge = aEdge; aCandidateTracker->mTargetIds = AutoTArray{aEdge.mTargetId}; aCandidateTracker->mEdgeFound = true; 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->mBestEdge.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; } 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->mBestEdge; } aCandidateTracker->mBestEdge = aEdge; aCandidateTracker->mTargetIds = AutoTArray{aEdge.mTargetId}; } else { if (aEdge.mPosition == aCandidateTracker->mBestEdge.mPosition) { aCandidateTracker->mTargetIds.AppendElement(aEdge.mTargetId); } if (aIsCloserThanSecond && isOnOppositeSide) { aCandidateTracker->mSecondBestEdge = aEdge; } } }; 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->mBestEdge.mPosition - aDestination); isCandidateOfSecondBest = std::abs(distanceFromDestination) < std::abs(NSCoordSaturatingSubtract( aCandidateTracker->mSecondBestEdge.mPosition, 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->mBestEdge.mPosition - aDestination) * aScrollingDirection; nscoord secondOvershoot = NSCoordSaturatingSubtract( aCandidateTracker->mSecondBestEdge.mPosition, 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->mBestEdge.mPosition - aStartPos); } else if (isPreferredStopAlways(aCandidateTracker->mBestEdge)) { // 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); } static void ProcessSnapPositions(CalcSnapPoints& aCalcSnapPoints, const ScrollSnapInfo& aSnapInfo) { for (const auto& target : aSnapInfo.mSnapTargets) { if (!target.IsValidFor(aCalcSnapPoints.Destination(), aSnapInfo.mSnapportSize)) { continue; } if (target.mSnapPositionX && aSnapInfo.mScrollSnapStrictnessX != StyleScrollSnapStrictness::None) { aCalcSnapPoints.AddVerticalEdge( {*target.mSnapPositionX, target.mScrollSnapStop, target.mTargetId}); } if (target.mSnapPositionY && aSnapInfo.mScrollSnapStrictnessY != StyleScrollSnapStrictness::None) { aCalcSnapPoints.AddHorizontalEdge( {*target.mSnapPositionY, target.mScrollSnapStop, target.mTargetId}); } } } 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); ProcessSnapPositions(calcSnapPoints, aSnapInfo); // 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); for (auto range : aSnapInfo.mXRangeWiderThanSnapport) { if (range.IsValid(clampedDestination.x, aSnapInfo.mSnapportSize.width) && calcSnapPoints.XDistanceBetweenBestAndSecondEdge() > aSnapInfo.mSnapportSize.width) { calcSnapPoints.AddVerticalEdge(CalcSnapPoints::SnapPosition{ clampedDestination.x, StyleScrollSnapStop::Normal, range.mTargetId}); break; } } for (auto range : aSnapInfo.mYRangeWiderThanSnapport) { if (range.IsValid(clampedDestination.y, aSnapInfo.mSnapportSize.height) && calcSnapPoints.YDistanceBetweenBestAndSecondEdge() > aSnapInfo.mSnapportSize.height) { calcSnapPoints.AddHorizontalEdge(CalcSnapPoints::SnapPosition{ clampedDestination.y, StyleScrollSnapStop::Normal, range.mTargetId}); break; } } bool snapped = false; auto finalPos = calcSnapPoints.GetBestEdge(); nscoord proximityThreshold = StaticPrefs::layout_css_scroll_snap_proximity_threshold(); proximityThreshold = nsPresContext::CSSPixelsToAppUnits(proximityThreshold); if (aSnapInfo.mScrollSnapStrictnessY == StyleScrollSnapStrictness::Proximity && std::abs(aDestination.y - finalPos.mPosition.y) > proximityThreshold) { finalPos.mPosition.y = aDestination.y; } else if (aSnapInfo.mScrollSnapStrictnessY != StyleScrollSnapStrictness::None && aDestination.y != finalPos.mPosition.y) { snapped = true; } if (aSnapInfo.mScrollSnapStrictnessX == StyleScrollSnapStrictness::Proximity && std::abs(aDestination.x - finalPos.mPosition.x) > proximityThreshold) { finalPos.mPosition.x = aDestination.x; } else if (aSnapInfo.mScrollSnapStrictnessX != StyleScrollSnapStrictness::None && aDestination.x != finalPos.mPosition.x) { snapped = true; } return snapped ? Some(finalPos) : Nothing(); } ScrollSnapTargetId ScrollSnapUtils::GetTargetIdFor(const nsIFrame* aFrame) { MOZ_ASSERT(aFrame && aFrame->GetContent()); return ScrollSnapTargetId{reinterpret_cast(aFrame->GetContent())}; } static std::pair, Maybe> GetCandidateInLastTargets( const layers::ScrollSnapInfo& aSnapInfo, const nsPoint& aCurrentPosition, const UniquePtr& aLastSnapTargetIds, const nsIContent* aFocusedContent) { ScrollSnapTargetId targetIdForFocusedContent = ScrollSnapTargetId::None; if (aFocusedContent && aFocusedContent->GetPrimaryFrame()) { targetIdForFocusedContent = ScrollSnapUtils::GetTargetIdFor(aFocusedContent->GetPrimaryFrame()); } // 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 const ScrollSnapInfo::SnapTarget* focusedTarget = nullptr; Maybe x, y; for (const auto& target : aSnapInfo.mSnapTargets) { if (!target.IsValidFor(aCurrentPosition, aSnapInfo.mSnapportSize)) { continue; } if (target.mSnapPositionX && aSnapInfo.mScrollSnapStrictnessX != StyleScrollSnapStrictness::None) { if (aLastSnapTargetIds->mIdsOnX.Contains(target.mTargetId)) { if (targetIdForFocusedContent == target.mTargetId) { // If we've already found the candidate on Y axis, but if snapping to // the point results this target is scrolled out, we can't use it. if ((y && !target.mSnapArea.Intersects( nsRect(nsPoint(*target.mSnapPositionX, *y), aSnapInfo.mSnapportSize)))) { y.reset(); } focusedTarget = ⌖ // If the focused one is valid, then it's the candidate. x = target.mSnapPositionX; } if (!x) { // Update the candidate on X axis only if // 1) we haven't yet found the candidate on Y axis // 2) or if we've found the candiate on Y axis and if snapping to the // candidate position result the target element is visible inside // the snapport. if (!y || (y && target.mSnapArea.Intersects( nsRect(nsPoint(*target.mSnapPositionX, *y), aSnapInfo.mSnapportSize)))) { x = target.mSnapPositionX; } } } } if (target.mSnapPositionY && aSnapInfo.mScrollSnapStrictnessY != StyleScrollSnapStrictness::None) { if (aLastSnapTargetIds->mIdsOnY.Contains(target.mTargetId)) { if (targetIdForFocusedContent == target.mTargetId) { NS_ASSERTION(!focusedTarget || focusedTarget == &target, "If the focused target has been found on X axis, the " "target should be same"); // If we've already found the candidate on X axis other than the // focused one, but if snapping to the point results this target is // scrolled out, we can't use it. if (!focusedTarget && (x && !target.mSnapArea.Intersects(nsRect( nsPoint(*x, *target.mSnapPositionY), aSnapInfo.mSnapportSize)))) { x.reset(); } focusedTarget = ⌖ y = target.mSnapPositionY; } if (!y) { if (!x || (x && target.mSnapArea.Intersects( nsRect(nsPoint(*x, *target.mSnapPositionY), aSnapInfo.mSnapportSize)))) { y = target.mSnapPositionY; } } } } // If we found candidates on both axes, it's the one we need. if (x && y && // If we haven't found the focused target, it's possible that we haven't // iterated it, don't break in such case. (targetIdForFocusedContent == ScrollSnapTargetId::None || focusedTarget)) { break; } } return {x, y}; } Maybe ScrollSnapUtils::GetSnapPointForResnap( const layers::ScrollSnapInfo& aSnapInfo, const nsRect& aScrollRange, const nsPoint& aCurrentPosition, const UniquePtr& aLastSnapTargetIds, const nsIContent* aFocusedContent) { if (!aLastSnapTargetIds) { return GetSnapPointForDestination(aSnapInfo, ScrollUnit::DEVICE_PIXELS, ScrollSnapFlags::IntendedEndPosition, aScrollRange, aCurrentPosition, aCurrentPosition); } auto [x, y] = GetCandidateInLastTargets(aSnapInfo, aCurrentPosition, aLastSnapTargetIds, aFocusedContent); 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); for (const auto& target : aSnapInfo.mSnapTargets) { if (!target.IsValidFor(newPosition, aSnapInfo.mSnapportSize)) { continue; } if (!x && target.mSnapPositionX && aSnapInfo.mScrollSnapStrictnessX != StyleScrollSnapStrictness::None) { calcSnapPoints.AddVerticalEdge( {*target.mSnapPositionX, target.mScrollSnapStop, target.mTargetId}); } if (!y && target.mSnapPositionY && aSnapInfo.mScrollSnapStrictnessY != StyleScrollSnapStrictness::None) { calcSnapPoints.AddHorizontalEdge( {*target.mSnapPositionY, target.mScrollSnapStop, target.mTargetId}); } } auto finalPos = calcSnapPoints.GetBestEdge(); if (!x) { x = Some(finalPos.mPosition.x); } if (!y) { y = Some(finalPos.mPosition.y); } } SnapTarget snapTarget{nsPoint(*x, *y)}; // Collect snap points where the position is still same as the new snap // position. for (const auto& target : aSnapInfo.mSnapTargets) { if (!target.IsValidFor(snapTarget.mPosition, aSnapInfo.mSnapportSize)) { continue; } if (target.mSnapPositionX && aSnapInfo.mScrollSnapStrictnessX != StyleScrollSnapStrictness::None && target.mSnapPositionX == x) { snapTarget.mTargetIds.mIdsOnX.AppendElement(target.mTargetId); } if (target.mSnapPositionY && aSnapInfo.mScrollSnapStrictnessY != StyleScrollSnapStrictness::None && target.mSnapPositionY == y) { snapTarget.mTargetIds.mIdsOnY.AppendElement(target.mTargetId); } } return Some(snapTarget); } void ScrollSnapUtils::PostPendingResnapIfNeededFor(nsIFrame* aFrame) { ScrollSnapTargetId id = GetTargetIdFor(aFrame); if (id == ScrollSnapTargetId::None) { return; } if (nsIScrollableFrame* sf = nsLayoutUtils::GetNearestScrollableFrame( aFrame, nsLayoutUtils::SCROLLABLE_SAME_DOC | nsLayoutUtils::SCROLLABLE_INCLUDE_HIDDEN)) { sf->PostPendingResnapIfNeeded(aFrame); } } void ScrollSnapUtils::PostPendingResnapFor(nsIFrame* aFrame) { if (nsIScrollableFrame* sf = nsLayoutUtils::GetNearestScrollableFrame( 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