From 2aa4a82499d4becd2284cdb482213d541b8804dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 16:29:10 +0200 Subject: Adding upstream version 86.0.1. Signed-off-by: Daniel Baumann --- layout/generic/ScrollAnchorContainer.cpp | 740 +++++++++++++++++++++++++++++++ 1 file changed, 740 insertions(+) create mode 100644 layout/generic/ScrollAnchorContainer.cpp (limited to 'layout/generic/ScrollAnchorContainer.cpp') diff --git a/layout/generic/ScrollAnchorContainer.cpp b/layout/generic/ScrollAnchorContainer.cpp new file mode 100644 index 0000000000..832fb2b66d --- /dev/null +++ b/layout/generic/ScrollAnchorContainer.cpp @@ -0,0 +1,740 @@ +/* -*- 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; + +static mozilla::LazyLogModule sAnchorLog("scrollanchor"); + +#ifdef DEBUG +# define ANCHOR_LOG(fmt, ...) \ + MOZ_LOG(sAnchorLog, LogLevel::Debug, \ + ("ANCHOR(%p, %s, root: %d): " fmt, this, \ + Frame() \ + ->PresContext() \ + ->Document() \ + ->GetDocumentURI() \ + ->GetSpecOrDefault() \ + .get(), \ + mScrollFrame->mIsRoot, ##__VA_ARGS__)); +#else +# define ANCHOR_LOG(...) +#endif + +namespace mozilla { +namespace layout { + +ScrollAnchorContainer::ScrollAnchorContainer(ScrollFrameHelper* aScrollFrame) + : mScrollFrame(aScrollFrame), + mAnchorNode(nullptr), + mLastAnchorOffset(0), + mDisabled(false), + mAnchorNodeIsDirty(true), + mApplyingAnchorAdjustment(false), + mSuppressAnchorAdjustment(false) {} + +ScrollAnchorContainer::~ScrollAnchorContainer() = default; + +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) { + // TODO(emilio, bug 1629280): This commented out assertion below should + // hold, but it may not in the case of reparenting-during-reflow (due to + // inline fragmentation or such). That looks fishy! + // + // We should either invalidate the anchor when reparenting any frame on the + // chain, or fix up the chain flags. + // + // MOZ_DIAGNOSTIC_ASSERT(frame->IsInScrollAnchorChain() != + // aInScrollAnchorChain); + frame->SetInScrollAnchorChain(aInScrollAnchorChain); + frame = frame->GetParent(); + } + MOZ_ASSERT(frame, + "The anchor node should be a descendant of the scrolled 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->ScrollableOverflowRectRelativeToSelf(); + overflowRect += continuation->GetOffsetTo(blockAncestor); + bounding = bounding.Union(overflowRect); + } + return nsLayoutUtils::TransformFrameRectToAncestor(blockAncestor, bounding, + aScrollFrame); + } + + nsRect borderRect = aCandidate->GetRectRelativeToSelf(); + nsRect overflowRect = aCandidate->ScrollableOverflowRectRelativeToSelf(); + + 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); +} + +bool ScrollAnchorContainer::CanMaintainAnchor() const { + if (!StaticPrefs::layout_css_scroll_anchoring_enabled()) { + return false; + } + + // If we've been disabled due to heuristics, we don't anchor anymore. + if (mDisabled) { + return false; + } + + const nsStyleDisplay& disp = *Frame()->StyleDisplay(); + // Don't select a scroll anchor if the scroll frame has `overflow-anchor: + // none`. + if (disp.mOverflowAnchor != mozilla::StyleOverflowAnchor::Auto) { + return false; + } + + // 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 + if (mScrollFrame->GetLogicalScrollPosition() == nsPoint()) { + return false; + } + + // 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 + if (Frame()->ChildrenHavePerspective()) { + return false; + } + + return true; +} + +void ScrollAnchorContainer::SelectAnchor() { + MOZ_ASSERT(mScrollFrame->mScrolledFrame); + MOZ_ASSERT(mAnchorNodeIsDirty); + + AUTO_PROFILER_LABEL("ScrollAnchorContainer::SelectAnchor", LAYOUT); + ANCHOR_LOG( + "Selecting anchor with scroll-port=%s.\n", + mozilla::ToString(mScrollFrame->GetVisualOptimalViewingRect()).c_str()); + + // Select a new scroll anchor + nsIFrame* oldAnchor = mAnchorNode; + if (CanMaintainAnchor()) { + MOZ_DIAGNOSTIC_ASSERT( + !mScrollFrame->mScrolledFrame->IsInScrollAnchorChain(), + "Our scrolled frame can't serve as or contain an anchor for an " + "ancestor if it can maintain its own anchor"); + ANCHOR_LOG("Beginning selection.\n"); + mAnchorNode = FindAnchorIn(mScrollFrame->mScrolledFrame); + } else { + ANCHOR_LOG("Skipping selection, doesn't maintain a scroll anchor.\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 nested scroll + // frame which maintains an anchor, 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, "Layout"_ns, + Frame()->PresContext()->Document(), nsContentUtils::eLAYOUT_PROPERTIES, + "ScrollAnchoringDisabledInContainer", arguments); +} + +void ScrollAnchorContainer::SuppressAdjustments() { + ANCHOR_LOG("Received a scroll anchor suppression for %p.\n", this); + mSuppressAnchorAdjustment = true; + + // Forward to our parent if appropriate, that is, if we don't maintain an + // anchor, and we can't maintain one. + // + // Note that we need to check !CanMaintainAnchor(), instead of just whether + // our frame is in the anchor chain of our ancestor as InvalidateAnchor() + // does, given some suppression triggers apply even for nodes that are not in + // the anchor chain. + if (!mAnchorNode && !CanMaintainAnchor()) { + if (ScrollAnchorContainer* container = FindFor(Frame())) { + ANCHOR_LOG(" > Forwarding to parent anchor\n"); + container->SuppressAdjustments(); + } + } +} + +void ScrollAnchorContainer::InvalidateAnchor(ScheduleSelection aSchedule) { + ANCHOR_LOG("Invalidating scroll anchor %p for %p.\n", mAnchorNode, this); + + if (mAnchorNode) { + SetAnchorFlags(mScrollFrame->mScrolledFrame, mAnchorNode, false); + } else if (mScrollFrame->mScrolledFrame->IsInScrollAnchorChain()) { + ANCHOR_LOG(" > Forwarding to parent anchor\n"); + // We don't maintain an anchor, and our scrolled frame is in the anchor + // chain of an ancestor. Invalidate that anchor. + // + // NOTE: Intentionally not forwarding aSchedule: Scheduling is always safe + // and not doing so is just an optimization. + FindFor(Frame())->InvalidateAnchor(); + } + mAnchorNode = nullptr; + mAnchorNodeIsDirty = true; + mLastAnchorOffset = 0; + + if (!CanMaintainAnchor() || aSchedule == ScheduleSelection::No) { + return; + } + + Frame()->PresShell()->PostPendingScrollAnchorSelection(this); +} + +void ScrollAnchorContainer::Destroy() { + InvalidateAnchor(ScheduleSelection::No); +} + +void ScrollAnchorContainer::ApplyAdjustments() { + if (!mAnchorNode || mAnchorNodeIsDirty || mDisabled || + mScrollFrame->HasPendingScrollRestoration() || + mScrollFrame->IsProcessingScrollEvent() || + mScrollFrame->IsScrollAnimating() || + mScrollFrame->GetScrollPosition() == nsPoint()) { + ANCHOR_LOG( + "Ignoring post-reflow (anchor=%p, dirty=%d, disabled=%d, " + "pendingRestoration=%d, scrollevent=%d, animating=%d, " + "zeroScrollPos=%d pendingSuppression=%d, " + "container=%p).\n", + mAnchorNode, mAnchorNodeIsDirty, mDisabled, + mScrollFrame->HasPendingScrollRestoration(), + mScrollFrame->IsProcessingScrollEvent(), + mScrollFrame->IsScrollAnimating(), + 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, ScrollOrigin::Relative); + mApplyingAnchorAdjustment = false; + + nsPresContext* pc = Frame()->PresContext(); + if (mScrollFrame->mIsRoot) { + pc->PresShell()->RootScrollFrameAdjusted(physicalAdjustment.y); + } + + // 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 has or could maintain its own anchor node. + const bool isScrollableWithAnchor = [&] { + nsIScrollableFrame* scrollable = do_QueryFrame(aFrame); + if (!scrollable) { + return false; + } + auto* anchor = scrollable->Anchor(); + return anchor->AnchorNode() || anchor->CanMaintainAnchor(); + }(); + + // We don't allow scroll anchors to be selected inside of nested scrollable + // frames which maintain an anchor node as it's not clear how an anchor + // adjustment should apply to multiple scrollable frames. + // + // It is important to descend into _some_ scrollable frames, specially + // overflow: hidden, as those don't generally maintain their own anchors, and + // it is a common case in the wild where scroll anchoring ought to work. + // + // We also don't allow scroll anchors to be selected inside of replaced + // elements (like ,