/* -*- 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 "ScrollAnchorContainer.h" #include "GeckoProfiler.h" #include "mozilla/dom/Text.h" #include "mozilla/PresShell.h" #include "mozilla/StaticPrefs_layout.h" #include "mozilla/ToString.h" #include "nsBlockFrame.h" #include "nsGfxScrollFrame.h" #include "nsIFrame.h" #include "nsIFrameInlines.h" #include "nsLayoutUtils.h" #include "nsPlaceholderFrame.h" using namespace mozilla::dom; #define ANCHOR_LOG(...) /* #define ANCHOR_LOG(fmt, ...) \ printf_stderr("ANCHOR(%p, %s, root: %d): " fmt, this, \ Frame() \ ->PresContext() \ ->Document() \ ->GetDocumentURI() \ ->GetSpecOrDefault() \ .get(), \ mScrollFrame->mIsRoot, ##__VA_ARGS__) */ namespace mozilla { namespace layout { ScrollAnchorContainer::ScrollAnchorContainer(ScrollFrameHelper* aScrollFrame) : mScrollFrame(aScrollFrame), mAnchorNode(nullptr), mLastAnchorOffset(0), mDisabled(false), mAnchorNodeIsDirty(true), mApplyingAnchorAdjustment(false), mSuppressAnchorAdjustment(false) {} ScrollAnchorContainer::~ScrollAnchorContainer() {} ScrollAnchorContainer* ScrollAnchorContainer::FindFor(nsIFrame* aFrame) { aFrame = aFrame->GetParent(); if (!aFrame) { return nullptr; } nsIScrollableFrame* nearest = nsLayoutUtils::GetNearestScrollableFrame( aFrame, nsLayoutUtils::SCROLLABLE_SAME_DOC | nsLayoutUtils::SCROLLABLE_INCLUDE_HIDDEN); if (nearest) { return nearest->Anchor(); } return nullptr; } nsIFrame* ScrollAnchorContainer::Frame() const { return mScrollFrame->mOuter; } nsIScrollableFrame* ScrollAnchorContainer::ScrollableFrame() const { return Frame()->GetScrollTargetFrame(); } /** * Set the appropriate frame flags for a frame that has become or is no longer * an anchor node. */ static void SetAnchorFlags(const nsIFrame* aScrolledFrame, nsIFrame* aAnchorNode, bool aInScrollAnchorChain) { nsIFrame* frame = aAnchorNode; while (frame && frame != aScrolledFrame) { MOZ_ASSERT( frame == aAnchorNode || !frame->IsScrollFrame(), "We shouldn't select an anchor node inside a nested scroll frame."); frame->SetInScrollAnchorChain(aInScrollAnchorChain); frame = frame->GetParent(); } MOZ_ASSERT(frame, "The anchor node should be a descendant of the scroll frame"); // If needed, invalidate the frame so that we start/stop highlighting the // anchor if (StaticPrefs::layout_css_scroll_anchoring_highlight()) { for (nsIFrame* frame = aAnchorNode->FirstContinuation(); !!frame; frame = frame->GetNextContinuation()) { frame->InvalidateFrame(); } } } /** * Compute the scrollable overflow rect [1] of aCandidate relative to * aScrollFrame with all transforms applied. * * The specification is ambiguous about what can be selected as a scroll anchor, * which makes the scroll anchoring bounding rect partially undefined [2]. This * code attempts to match the implementation in Blink. * * An additional unspecified behavior is that any scrollable overflow before the * border start edge in the block axis of aScrollFrame should be clamped. This * is to prevent absolutely positioned descendant elements from being able to * trigger scroll adjustments [3]. * * [1] * https://drafts.csswg.org/css-scroll-anchoring-1/#scroll-anchoring-bounding-rect * [2] https://github.com/w3c/csswg-drafts/issues/3478 * [3] https://bugzilla.mozilla.org/show_bug.cgi?id=1519541 */ static nsRect FindScrollAnchoringBoundingRect(const nsIFrame* aScrollFrame, nsIFrame* aCandidate) { MOZ_ASSERT(nsLayoutUtils::IsProperAncestorFrame(aScrollFrame, aCandidate)); if (!!Text::FromNodeOrNull(aCandidate->GetContent())) { // This is a frame for a text node. The spec says we need to accumulate the // union of all line boxes in the coordinate space of the scroll frame // accounting for transforms. // // To do this, we translate and accumulate the overflow rect for each text // continuation to the coordinate space of the nearest ancestor block // frame. Then we transform the resulting rect into the coordinate space of // the scroll frame. // // Transforms aren't allowed on non-replaced inline boxes, so we can assume // that these text node continuations will have the same transform as their // nearest block ancestor. And it should be faster to transform their union // rather than individually transforming each overflow rect // // XXX for fragmented blocks, blockAncestor will be an ancestor only to the // text continuations in the first block continuation. GetOffsetTo // should continue to work, but is it correct with transforms or a // performance hazard? nsIFrame* blockAncestor = nsLayoutUtils::FindNearestBlockAncestor(aCandidate); MOZ_ASSERT( nsLayoutUtils::IsProperAncestorFrame(aScrollFrame, blockAncestor)); nsRect bounding; for (nsIFrame* continuation = aCandidate->FirstContinuation(); continuation; continuation = continuation->GetNextContinuation()) { nsRect overflowRect = continuation->GetScrollableOverflowRectRelativeToSelf(); overflowRect += continuation->GetOffsetTo(blockAncestor); bounding = bounding.Union(overflowRect); } return nsLayoutUtils::TransformFrameRectToAncestor(blockAncestor, bounding, aScrollFrame); } nsRect borderRect = aCandidate->GetRectRelativeToSelf(); nsRect overflowRect = aCandidate->GetScrollableOverflowRectRelativeToSelf(); NS_ASSERTION(overflowRect.Contains(borderRect), "overflow rect must include border rect, and the clamping logic " "here depends on that"); // Clamp the scrollable overflow rect to the border start edge on the block // axis of the scroll frame WritingMode writingMode = aScrollFrame->GetWritingMode(); switch (writingMode.GetBlockDir()) { case WritingMode::eBlockTB: { overflowRect.SetBoxY(borderRect.Y(), overflowRect.YMost()); break; } case WritingMode::eBlockLR: { overflowRect.SetBoxX(borderRect.X(), overflowRect.XMost()); break; } case WritingMode::eBlockRL: { overflowRect.SetBoxX(overflowRect.X(), borderRect.XMost()); break; } } nsRect transformed = nsLayoutUtils::TransformFrameRectToAncestor( aCandidate, overflowRect, aScrollFrame); return transformed; } /** * Compute the offset between the scrollable overflow rect start edge of * aCandidate and the scroll-port start edge of aScrollFrame, in the block axis * of aScrollFrame. */ static nscoord FindScrollAnchoringBoundingOffset( const ScrollFrameHelper* aScrollFrame, nsIFrame* aCandidate) { WritingMode writingMode = aScrollFrame->mOuter->GetWritingMode(); nsRect physicalBounding = FindScrollAnchoringBoundingRect(aScrollFrame->mOuter, aCandidate); LogicalRect logicalBounding(writingMode, physicalBounding, aScrollFrame->mScrolledFrame->GetSize()); return logicalBounding.BStart(writingMode); } void ScrollAnchorContainer::SelectAnchor() { MOZ_ASSERT(mScrollFrame->mScrolledFrame); MOZ_ASSERT(mAnchorNodeIsDirty); if (mDisabled || !StaticPrefs::layout_css_scroll_anchoring_enabled()) { return; } AUTO_PROFILER_LABEL("ScrollAnchorContainer::SelectAnchor", LAYOUT); ANCHOR_LOG( "Selecting anchor for with scroll-port=%s.\n", mozilla::ToString(mScrollFrame->GetVisualOptimalViewingRect()).c_str()); const nsStyleDisplay* disp = Frame()->StyleDisplay(); // Don't select a scroll anchor if the scroll frame has `overflow-anchor: // none`. bool overflowAnchor = disp->mOverflowAnchor == mozilla::StyleOverflowAnchor::Auto; // Or if the scroll frame has not been scrolled from the logical origin. This // is not in the specification [1], but Blink does this. // // [1] https://github.com/w3c/csswg-drafts/issues/3319 bool isScrolled = mScrollFrame->GetLogicalScrollPosition() != nsPoint(); // Or if there is perspective that could affect the scrollable overflow rect // for descendant frames. This is not in the specification as Blink doesn't // share this behavior with perspective [1]. // // [1] https://github.com/w3c/csswg-drafts/issues/3322 bool hasPerspective = Frame()->ChildrenHavePerspective(); // Select a new scroll anchor nsIFrame* oldAnchor = mAnchorNode; if (overflowAnchor && isScrolled && !hasPerspective) { ANCHOR_LOG("Beginning candidate selection.\n"); mAnchorNode = FindAnchorIn(mScrollFrame->mScrolledFrame); } else { if (!overflowAnchor) { ANCHOR_LOG("Skipping candidate selection for `overflow-anchor: none`\n"); } if (!isScrolled) { ANCHOR_LOG("Skipping candidate selection for not being scrolled\n"); } if (hasPerspective) { ANCHOR_LOG( "Skipping candidate selection for scroll frame with perspective\n"); } mAnchorNode = nullptr; } // Update the anchor flags if needed if (oldAnchor != mAnchorNode) { ANCHOR_LOG("Anchor node has changed from (%p) to (%p).\n", oldAnchor, mAnchorNode); // Unset all flags for the old scroll anchor if (oldAnchor) { SetAnchorFlags(mScrollFrame->mScrolledFrame, oldAnchor, false); } // Set all flags for the new scroll anchor if (mAnchorNode) { // Anchor selection will never select a descendant of a different scroll // frame, so we can set flags without conflicting with other scroll // anchor containers. SetAnchorFlags(mScrollFrame->mScrolledFrame, mAnchorNode, true); } } else { ANCHOR_LOG("Anchor node has remained (%p).\n", mAnchorNode); } // Calculate the position to use for scroll adjustments if (mAnchorNode) { mLastAnchorOffset = FindScrollAnchoringBoundingOffset(mScrollFrame, mAnchorNode); ANCHOR_LOG("Using last anchor offset = %d.\n", mLastAnchorOffset); } else { mLastAnchorOffset = 0; } mAnchorNodeIsDirty = false; } void ScrollAnchorContainer::UserScrolled() { if (mApplyingAnchorAdjustment) { return; } InvalidateAnchor(); mConsecutiveScrollAnchoringAdjustments = SaturateUint32(0); mConsecutiveScrollAnchoringAdjustmentLength = 0; } void ScrollAnchorContainer::AdjustmentMade(nscoord aAdjustment) { // A reasonably large number of times that we want to check for this. If we // haven't hit this limit after these many attempts we assume we'll never hit // it. // // This is to prevent the number getting too large and making the limit round // to zero by mere precision error. // // 100k should be enough for anyone :) static const uint32_t kAnchorCheckCountLimit = 100000; // Zero-length adjustments are common & don't have side effects, so we don't // want them to consider them here; they'd bias our average towards 0. MOZ_ASSERT(aAdjustment, "Don't call this API for zero-length adjustments"); mConsecutiveScrollAnchoringAdjustments++; mConsecutiveScrollAnchoringAdjustmentLength = NSCoordSaturatingAdd( mConsecutiveScrollAnchoringAdjustmentLength, aAdjustment); uint32_t maxConsecutiveAdjustments = StaticPrefs::layout_css_scroll_anchoring_max_consecutive_adjustments(); if (!maxConsecutiveAdjustments) { return; } uint32_t consecutiveAdjustments = mConsecutiveScrollAnchoringAdjustments.value(); if (consecutiveAdjustments < maxConsecutiveAdjustments || consecutiveAdjustments > kAnchorCheckCountLimit) { return; } auto cssPixels = CSSPixel::FromAppUnits(mConsecutiveScrollAnchoringAdjustmentLength); double average = double(cssPixels) / consecutiveAdjustments; uint32_t minAverage = StaticPrefs:: layout_css_scroll_anchoring_min_average_adjustment_threshold(); if (MOZ_LIKELY(std::abs(average) >= double(minAverage))) { return; } mDisabled = true; ANCHOR_LOG( "Disabled scroll anchoring for container: " "%f average, %f total out of %u consecutive adjustments\n", average, float(cssPixels), consecutiveAdjustments); AutoTArray arguments; arguments.AppendElement()->AppendInt(consecutiveAdjustments); arguments.AppendElement()->AppendFloat(average); arguments.AppendElement()->AppendFloat(cssPixels); nsContentUtils::ReportToConsole( nsIScriptError::warningFlag, NS_LITERAL_CSTRING("Layout"), Frame()->PresContext()->Document(), nsContentUtils::eLAYOUT_PROPERTIES, "ScrollAnchoringDisabledInContainer", arguments); } void ScrollAnchorContainer::SuppressAdjustments() { ANCHOR_LOG("Received a scroll anchor suppression for %p.\n", this); mSuppressAnchorAdjustment = true; } void ScrollAnchorContainer::InvalidateAnchor(ScheduleSelection aSchedule) { ANCHOR_LOG("Invalidating scroll anchor %p for %p.\n", mAnchorNode, this); if (mAnchorNode) { SetAnchorFlags(mScrollFrame->mScrolledFrame, mAnchorNode, false); } mAnchorNode = nullptr; mAnchorNodeIsDirty = true; mLastAnchorOffset = 0; if (mDisabled || aSchedule == ScheduleSelection::No || !StaticPrefs::layout_css_scroll_anchoring_enabled()) { return; } Frame()->PresShell()->PostPendingScrollAnchorSelection(this); } void ScrollAnchorContainer::Destroy() { InvalidateAnchor(ScheduleSelection::No); } void ScrollAnchorContainer::ApplyAdjustments() { if (!mAnchorNode || mAnchorNodeIsDirty || mDisabled || mScrollFrame->HasPendingScrollRestoration() || mScrollFrame->IsProcessingScrollEvent() || mScrollFrame->IsProcessingAsyncScroll() || mScrollFrame->mApzSmoothScrollDestination.isSome() || mScrollFrame->GetScrollPosition() == nsPoint()) { ANCHOR_LOG( "Ignoring post-reflow (anchor=%p, dirty=%d, disabled=%d, " "pendingRestoration=%d, scrollevent=%d, asyncScroll=%d, " "apzSmoothDestination=%d, zeroScrollPos=%d pendingSuppression=%d, " "container=%p).\n", mAnchorNode, mAnchorNodeIsDirty, mDisabled, mScrollFrame->HasPendingScrollRestoration(), mScrollFrame->IsProcessingScrollEvent(), mScrollFrame->IsProcessingAsyncScroll(), mScrollFrame->mApzSmoothScrollDestination.isSome(), mScrollFrame->GetScrollPosition() == nsPoint(), mSuppressAnchorAdjustment, this); if (mSuppressAnchorAdjustment) { mSuppressAnchorAdjustment = false; InvalidateAnchor(); } return; } nscoord current = FindScrollAnchoringBoundingOffset(mScrollFrame, mAnchorNode); nscoord logicalAdjustment = current - mLastAnchorOffset; WritingMode writingMode = Frame()->GetWritingMode(); ANCHOR_LOG("Anchor has moved from %d to %d.\n", mLastAnchorOffset, current); if (logicalAdjustment == 0) { ANCHOR_LOG("Ignoring zero delta anchor adjustment for %p.\n", this); mSuppressAnchorAdjustment = false; return; } if (mSuppressAnchorAdjustment) { ANCHOR_LOG("Applying anchor adjustment suppression for %p.\n", this); mSuppressAnchorAdjustment = false; InvalidateAnchor(); return; } ANCHOR_LOG("Applying anchor adjustment of %d in %s with anchor %p.\n", logicalAdjustment, ToString(writingMode).c_str(), mAnchorNode); AdjustmentMade(logicalAdjustment); nsPoint physicalAdjustment; switch (writingMode.GetBlockDir()) { case WritingMode::eBlockTB: { physicalAdjustment.y = logicalAdjustment; break; } case WritingMode::eBlockLR: { physicalAdjustment.x = logicalAdjustment; break; } case WritingMode::eBlockRL: { physicalAdjustment.x = -logicalAdjustment; break; } } MOZ_RELEASE_ASSERT(!mApplyingAnchorAdjustment); // We should use AutoRestore here, but that doesn't work with bitfields mApplyingAnchorAdjustment = true; mScrollFrame->ScrollTo(mScrollFrame->GetScrollPosition() + physicalAdjustment, ScrollMode::Instant, nsGkAtoms::relative); mApplyingAnchorAdjustment = false; nsPresContext* pc = Frame()->PresContext(); if (mScrollFrame->mIsRoot) { pc->PresShell()->RootScrollFrameAdjusted(physicalAdjustment.y); } pc->Document()->UpdateForScrollAnchorAdjustment(logicalAdjustment); // The anchor position may not be in the same relative position after // adjustment. Update ourselves so we have consistent state. mLastAnchorOffset = FindScrollAnchoringBoundingOffset(mScrollFrame, mAnchorNode); } ScrollAnchorContainer::ExamineResult ScrollAnchorContainer::ExamineAnchorCandidate(nsIFrame* aFrame) const { #ifdef DEBUG_FRAME_DUMP nsCString tag = aFrame->ListTag(); ANCHOR_LOG("\tVisiting frame=%s (%p).\n", tag.get(), aFrame); #else ANCHOR_LOG("\t\tVisiting frame=%p.\n", aFrame); #endif bool isText = !!Text::FromNodeOrNull(aFrame->GetContent()); bool isContinuation = !!aFrame->GetPrevContinuation(); if (isText && isContinuation) { ANCHOR_LOG("\t\tExcluding continuation text node.\n"); return ExamineResult::Exclude; } // Check if the author has opted out of scroll anchoring for this frame // and its descendants. const nsStyleDisplay* disp = aFrame->StyleDisplay(); if (disp->mOverflowAnchor == mozilla::StyleOverflowAnchor::None) { ANCHOR_LOG("\t\tExcluding `overflow-anchor: none`.\n"); return ExamineResult::Exclude; } // Sticky positioned elements can move with the scroll frame, making them // unsuitable scroll anchors. This isn't in the specification yet [1], but // matches Blink's implementation. // // [1] https://github.com/w3c/csswg-drafts/issues/3319 if (aFrame->IsStickyPositioned()) { ANCHOR_LOG("\t\tExcluding `position: sticky`.\n"); return ExamineResult::Exclude; } // The frame for a
element has a non-zero area, but Blink treats them // as if they have no area, so exclude them specially. if (aFrame->IsBrFrame()) { ANCHOR_LOG("\t\tExcluding
.\n"); return ExamineResult::Exclude; } // Exclude frames that aren't accessible to content. bool isChrome = aFrame->GetContent() && aFrame->GetContent()->ChromeOnlyAccess(); bool isPseudo = aFrame->Style()->IsPseudoElement(); if (isChrome && !isPseudo) { ANCHOR_LOG("\t\tExcluding chrome only content.\n"); return ExamineResult::Exclude; } const bool isReplaced = aFrame->IsFrameOfType(nsIFrame::eReplaced); const bool isNonReplacedInline = aFrame->StyleDisplay()->IsInlineInsideStyle() && !isReplaced; const bool isAnonBox = aFrame->Style()->IsAnonBox(); // See if this frame could have its own anchor node. We could check // IsScrollFrame(), but that would miss nsListControlFrame which is not a // scroll frame, but still inherits from nsHTMLScrollFrame. nsIScrollableFrame* scrollable = do_QueryFrame(aFrame); // We don't allow scroll anchors to be selected inside of scrollable frames as // it's not clear how an anchor adjustment should apply to multiple scrollable // frames. Blink allows this to happen, but they're not sure why [1]. // // We also don't allow scroll anchors to be selected inside of replaced // elements (like ,