1 /* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2 /* vim: set ts=8 sts=2 et sw=2 tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4  * License, v. 2.0. If a copy of the MPL was not distributed with this
5  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 
7 #include "ScrollAnchorContainer.h"
8 
9 #include "mozilla/dom/Text.h"
10 #include "mozilla/ScopeExit.h"
11 #include "mozilla/PresShell.h"
12 #include "mozilla/ProfilerLabels.h"
13 #include "mozilla/StaticPrefs_layout.h"
14 #include "mozilla/ToString.h"
15 #include "nsBlockFrame.h"
16 #include "nsGfxScrollFrame.h"
17 #include "nsIFrame.h"
18 #include "nsIFrameInlines.h"
19 #include "nsLayoutUtils.h"
20 #include "nsPlaceholderFrame.h"
21 
22 using namespace mozilla::dom;
23 
24 #ifdef DEBUG
25 static mozilla::LazyLogModule sAnchorLog("scrollanchor");
26 
27 #  define ANCHOR_LOG(fmt, ...)                       \
28     MOZ_LOG(sAnchorLog, LogLevel::Debug,             \
29             ("ANCHOR(%p, %s, root: %d): " fmt, this, \
30              Frame()                                 \
31                  ->PresContext()                     \
32                  ->Document()                        \
33                  ->GetDocumentURI()                  \
34                  ->GetSpecOrDefault()                \
35                  .get(),                             \
36              mScrollFrame->mIsRoot, ##__VA_ARGS__));
37 #else
38 #  define ANCHOR_LOG(...)
39 #endif
40 
41 namespace mozilla {
42 namespace layout {
43 
ScrollAnchorContainer(ScrollFrameHelper * aScrollFrame)44 ScrollAnchorContainer::ScrollAnchorContainer(ScrollFrameHelper* aScrollFrame)
45     : mScrollFrame(aScrollFrame),
46       mAnchorNode(nullptr),
47       mLastAnchorOffset(0),
48       mDisabled(false),
49       mAnchorMightBeSubOptimal(false),
50       mAnchorNodeIsDirty(true),
51       mApplyingAnchorAdjustment(false),
52       mSuppressAnchorAdjustment(false) {}
53 
54 ScrollAnchorContainer::~ScrollAnchorContainer() = default;
55 
FindFor(nsIFrame * aFrame)56 ScrollAnchorContainer* ScrollAnchorContainer::FindFor(nsIFrame* aFrame) {
57   aFrame = aFrame->GetParent();
58   if (!aFrame) {
59     return nullptr;
60   }
61   nsIScrollableFrame* nearest = nsLayoutUtils::GetNearestScrollableFrame(
62       aFrame, nsLayoutUtils::SCROLLABLE_SAME_DOC |
63                   nsLayoutUtils::SCROLLABLE_INCLUDE_HIDDEN);
64   if (nearest) {
65     return nearest->Anchor();
66   }
67   return nullptr;
68 }
69 
Frame() const70 nsIFrame* ScrollAnchorContainer::Frame() const { return mScrollFrame->mOuter; }
71 
ScrollableFrame() const72 nsIScrollableFrame* ScrollAnchorContainer::ScrollableFrame() const {
73   return Frame()->GetScrollTargetFrame();
74 }
75 
76 /**
77  * Set the appropriate frame flags for a frame that has become or is no longer
78  * an anchor node.
79  */
SetAnchorFlags(const nsIFrame * aScrolledFrame,nsIFrame * aAnchorNode,bool aInScrollAnchorChain)80 static void SetAnchorFlags(const nsIFrame* aScrolledFrame,
81                            nsIFrame* aAnchorNode, bool aInScrollAnchorChain) {
82   nsIFrame* frame = aAnchorNode;
83   while (frame && frame != aScrolledFrame) {
84     // TODO(emilio, bug 1629280): This commented out assertion below should
85     // hold, but it may not in the case of reparenting-during-reflow (due to
86     // inline fragmentation or such). That looks fishy!
87     //
88     // We should either invalidate the anchor when reparenting any frame on the
89     // chain, or fix up the chain flags.
90     //
91     // MOZ_DIAGNOSTIC_ASSERT(frame->IsInScrollAnchorChain() !=
92     //                       aInScrollAnchorChain);
93     frame->SetInScrollAnchorChain(aInScrollAnchorChain);
94     frame = frame->GetParent();
95   }
96   MOZ_ASSERT(frame,
97              "The anchor node should be a descendant of the scrolled frame");
98   // If needed, invalidate the frame so that we start/stop highlighting the
99   // anchor
100   if (StaticPrefs::layout_css_scroll_anchoring_highlight()) {
101     for (nsIFrame* frame = aAnchorNode->FirstContinuation(); !!frame;
102          frame = frame->GetNextContinuation()) {
103       frame->InvalidateFrame();
104     }
105   }
106 }
107 
108 /**
109  * Compute the scrollable overflow rect [1] of aCandidate relative to
110  * aScrollFrame with all transforms applied.
111  *
112  * The specification is ambiguous about what can be selected as a scroll anchor,
113  * which makes the scroll anchoring bounding rect partially undefined [2]. This
114  * code attempts to match the implementation in Blink.
115  *
116  * An additional unspecified behavior is that any scrollable overflow before the
117  * border start edge in the block axis of aScrollFrame should be clamped. This
118  * is to prevent absolutely positioned descendant elements from being able to
119  * trigger scroll adjustments [3].
120  *
121  * [1]
122  * https://drafts.csswg.org/css-scroll-anchoring-1/#scroll-anchoring-bounding-rect
123  * [2] https://github.com/w3c/csswg-drafts/issues/3478
124  * [3] https://bugzilla.mozilla.org/show_bug.cgi?id=1519541
125  */
FindScrollAnchoringBoundingRect(const nsIFrame * aScrollFrame,nsIFrame * aCandidate)126 static nsRect FindScrollAnchoringBoundingRect(const nsIFrame* aScrollFrame,
127                                               nsIFrame* aCandidate) {
128   MOZ_ASSERT(nsLayoutUtils::IsProperAncestorFrame(aScrollFrame, aCandidate));
129   if (!!Text::FromNodeOrNull(aCandidate->GetContent())) {
130     // This is a frame for a text node. The spec says we need to accumulate the
131     // union of all line boxes in the coordinate space of the scroll frame
132     // accounting for transforms.
133     //
134     // To do this, we translate and accumulate the overflow rect for each text
135     // continuation to the coordinate space of the nearest ancestor block
136     // frame. Then we transform the resulting rect into the coordinate space of
137     // the scroll frame.
138     //
139     // Transforms aren't allowed on non-replaced inline boxes, so we can assume
140     // that these text node continuations will have the same transform as their
141     // nearest block ancestor. And it should be faster to transform their union
142     // rather than individually transforming each overflow rect
143     //
144     // XXX for fragmented blocks, blockAncestor will be an ancestor only to the
145     //     text continuations in the first block continuation. GetOffsetTo
146     //     should continue to work, but is it correct with transforms or a
147     //     performance hazard?
148     nsIFrame* blockAncestor =
149         nsLayoutUtils::FindNearestBlockAncestor(aCandidate);
150     MOZ_ASSERT(
151         nsLayoutUtils::IsProperAncestorFrame(aScrollFrame, blockAncestor));
152     nsRect bounding;
153     for (nsIFrame* continuation = aCandidate->FirstContinuation(); continuation;
154          continuation = continuation->GetNextContinuation()) {
155       nsRect overflowRect =
156           continuation->ScrollableOverflowRectRelativeToSelf();
157       overflowRect += continuation->GetOffsetTo(blockAncestor);
158       bounding = bounding.Union(overflowRect);
159     }
160     return nsLayoutUtils::TransformFrameRectToAncestor(blockAncestor, bounding,
161                                                        aScrollFrame);
162   }
163 
164   nsRect borderRect = aCandidate->GetRectRelativeToSelf();
165   nsRect overflowRect = aCandidate->ScrollableOverflowRectRelativeToSelf();
166 
167   NS_ASSERTION(overflowRect.Contains(borderRect),
168                "overflow rect must include border rect, and the clamping logic "
169                "here depends on that");
170 
171   // Clamp the scrollable overflow rect to the border start edge on the block
172   // axis of the scroll frame
173   WritingMode writingMode = aScrollFrame->GetWritingMode();
174   switch (writingMode.GetBlockDir()) {
175     case WritingMode::eBlockTB: {
176       overflowRect.SetBoxY(borderRect.Y(), overflowRect.YMost());
177       break;
178     }
179     case WritingMode::eBlockLR: {
180       overflowRect.SetBoxX(borderRect.X(), overflowRect.XMost());
181       break;
182     }
183     case WritingMode::eBlockRL: {
184       overflowRect.SetBoxX(overflowRect.X(), borderRect.XMost());
185       break;
186     }
187   }
188 
189   nsRect transformed = nsLayoutUtils::TransformFrameRectToAncestor(
190       aCandidate, overflowRect, aScrollFrame);
191   return transformed;
192 }
193 
194 /**
195  * Compute the offset between the scrollable overflow rect start edge of
196  * aCandidate and the scroll-port start edge of aScrollFrame, in the block axis
197  * of aScrollFrame.
198  */
FindScrollAnchoringBoundingOffset(const ScrollFrameHelper * aScrollFrame,nsIFrame * aCandidate)199 static nscoord FindScrollAnchoringBoundingOffset(
200     const ScrollFrameHelper* aScrollFrame, nsIFrame* aCandidate) {
201   WritingMode writingMode = aScrollFrame->mOuter->GetWritingMode();
202   nsRect physicalBounding =
203       FindScrollAnchoringBoundingRect(aScrollFrame->mOuter, aCandidate);
204   LogicalRect logicalBounding(writingMode, physicalBounding,
205                               aScrollFrame->mScrolledFrame->GetSize());
206   return logicalBounding.BStart(writingMode);
207 }
208 
CanMaintainAnchor() const209 bool ScrollAnchorContainer::CanMaintainAnchor() const {
210   if (!StaticPrefs::layout_css_scroll_anchoring_enabled()) {
211     return false;
212   }
213 
214   // If we've been disabled due to heuristics, we don't anchor anymore.
215   if (mDisabled) {
216     return false;
217   }
218 
219   const nsStyleDisplay& disp = *Frame()->StyleDisplay();
220   // Don't select a scroll anchor if the scroll frame has `overflow-anchor:
221   // none`.
222   if (disp.mOverflowAnchor != mozilla::StyleOverflowAnchor::Auto) {
223     return false;
224   }
225 
226   // Or if the scroll frame has not been scrolled from the logical origin. This
227   // is not in the specification [1], but Blink does this.
228   //
229   // [1] https://github.com/w3c/csswg-drafts/issues/3319
230   if (mScrollFrame->GetLogicalScrollPosition() == nsPoint()) {
231     return false;
232   }
233 
234   // Or if there is perspective that could affect the scrollable overflow rect
235   // for descendant frames. This is not in the specification as Blink doesn't
236   // share this behavior with perspective [1].
237   //
238   // [1] https://github.com/w3c/csswg-drafts/issues/3322
239   if (Frame()->ChildrenHavePerspective()) {
240     return false;
241   }
242 
243   return true;
244 }
245 
SelectAnchor()246 void ScrollAnchorContainer::SelectAnchor() {
247   MOZ_ASSERT(mScrollFrame->mScrolledFrame);
248   MOZ_ASSERT(mAnchorNodeIsDirty);
249 
250   AUTO_PROFILER_LABEL("ScrollAnchorContainer::SelectAnchor", LAYOUT);
251   ANCHOR_LOG(
252       "Selecting anchor with scroll-port=%s.\n",
253       mozilla::ToString(mScrollFrame->GetVisualOptimalViewingRect()).c_str());
254 
255   // Select a new scroll anchor
256   nsIFrame* oldAnchor = mAnchorNode;
257   if (CanMaintainAnchor()) {
258     MOZ_DIAGNOSTIC_ASSERT(
259         !mScrollFrame->mScrolledFrame->IsInScrollAnchorChain(),
260         "Our scrolled frame can't serve as or contain an anchor for an "
261         "ancestor if it can maintain its own anchor");
262     ANCHOR_LOG("Beginning selection.\n");
263     mAnchorNode = FindAnchorIn(mScrollFrame->mScrolledFrame);
264   } else {
265     ANCHOR_LOG("Skipping selection, doesn't maintain a scroll anchor.\n");
266     mAnchorNode = nullptr;
267   }
268   mAnchorMightBeSubOptimal =
269       mAnchorNode && mAnchorNode->HasAnyStateBits(NS_FRAME_HAS_DIRTY_CHILDREN);
270 
271   // Update the anchor flags if needed
272   if (oldAnchor != mAnchorNode) {
273     ANCHOR_LOG("Anchor node has changed from (%p) to (%p).\n", oldAnchor,
274                mAnchorNode);
275 
276     // Unset all flags for the old scroll anchor
277     if (oldAnchor) {
278       SetAnchorFlags(mScrollFrame->mScrolledFrame, oldAnchor, false);
279     }
280 
281     // Set all flags for the new scroll anchor
282     if (mAnchorNode) {
283       // Anchor selection will never select a descendant of a nested scroll
284       // frame which maintains an anchor, so we can set flags without
285       // conflicting with other scroll anchor containers.
286       SetAnchorFlags(mScrollFrame->mScrolledFrame, mAnchorNode, true);
287     }
288   } else {
289     ANCHOR_LOG("Anchor node has remained (%p).\n", mAnchorNode);
290   }
291 
292   // Calculate the position to use for scroll adjustments
293   if (mAnchorNode) {
294     mLastAnchorOffset =
295         FindScrollAnchoringBoundingOffset(mScrollFrame, mAnchorNode);
296     ANCHOR_LOG("Using last anchor offset = %d.\n", mLastAnchorOffset);
297   } else {
298     mLastAnchorOffset = 0;
299   }
300 
301   mAnchorNodeIsDirty = false;
302 }
303 
UserScrolled()304 void ScrollAnchorContainer::UserScrolled() {
305   if (mApplyingAnchorAdjustment) {
306     return;
307   }
308   InvalidateAnchor();
309   mConsecutiveScrollAnchoringAdjustments = SaturateUint32(0);
310   mConsecutiveScrollAnchoringAdjustmentLength = 0;
311 }
312 
AdjustmentMade(nscoord aAdjustment)313 void ScrollAnchorContainer::AdjustmentMade(nscoord aAdjustment) {
314   // A reasonably large number of times that we want to check for this. If we
315   // haven't hit this limit after these many attempts we assume we'll never hit
316   // it.
317   //
318   // This is to prevent the number getting too large and making the limit round
319   // to zero by mere precision error.
320   //
321   // 100k should be enough for anyone :)
322   static const uint32_t kAnchorCheckCountLimit = 100000;
323 
324   // Zero-length adjustments are common & don't have side effects, so we don't
325   // want them to consider them here; they'd bias our average towards 0.
326   MOZ_ASSERT(aAdjustment, "Don't call this API for zero-length adjustments");
327 
328   mConsecutiveScrollAnchoringAdjustments++;
329   mConsecutiveScrollAnchoringAdjustmentLength = NSCoordSaturatingAdd(
330       mConsecutiveScrollAnchoringAdjustmentLength, aAdjustment);
331 
332   uint32_t maxConsecutiveAdjustments =
333       StaticPrefs::layout_css_scroll_anchoring_max_consecutive_adjustments();
334 
335   if (!maxConsecutiveAdjustments) {
336     return;
337   }
338 
339   uint32_t consecutiveAdjustments =
340       mConsecutiveScrollAnchoringAdjustments.value();
341   if (consecutiveAdjustments < maxConsecutiveAdjustments ||
342       consecutiveAdjustments > kAnchorCheckCountLimit) {
343     return;
344   }
345 
346   auto cssPixels =
347       CSSPixel::FromAppUnits(mConsecutiveScrollAnchoringAdjustmentLength);
348   double average = double(cssPixels) / consecutiveAdjustments;
349   uint32_t minAverage = StaticPrefs::
350       layout_css_scroll_anchoring_min_average_adjustment_threshold();
351   if (MOZ_LIKELY(std::abs(average) >= double(minAverage))) {
352     return;
353   }
354 
355   mDisabled = true;
356 
357   ANCHOR_LOG(
358       "Disabled scroll anchoring for container: "
359       "%f average, %f total out of %u consecutive adjustments\n",
360       average, float(cssPixels), consecutiveAdjustments);
361 
362   AutoTArray<nsString, 3> arguments;
363   arguments.AppendElement()->AppendInt(consecutiveAdjustments);
364   arguments.AppendElement()->AppendFloat(average);
365   arguments.AppendElement()->AppendFloat(cssPixels);
366 
367   nsContentUtils::ReportToConsole(
368       nsIScriptError::warningFlag, "Layout"_ns,
369       Frame()->PresContext()->Document(), nsContentUtils::eLAYOUT_PROPERTIES,
370       "ScrollAnchoringDisabledInContainer", arguments);
371 }
372 
SuppressAdjustments()373 void ScrollAnchorContainer::SuppressAdjustments() {
374   ANCHOR_LOG("Received a scroll anchor suppression for %p.\n", this);
375   mSuppressAnchorAdjustment = true;
376 
377   // Forward to our parent if appropriate, that is, if we don't maintain an
378   // anchor, and we can't maintain one.
379   //
380   // Note that we need to check !CanMaintainAnchor(), instead of just whether
381   // our frame is in the anchor chain of our ancestor as InvalidateAnchor()
382   // does, given some suppression triggers apply even for nodes that are not in
383   // the anchor chain.
384   if (!mAnchorNode && !CanMaintainAnchor()) {
385     if (ScrollAnchorContainer* container = FindFor(Frame())) {
386       ANCHOR_LOG(" > Forwarding to parent anchor\n");
387       container->SuppressAdjustments();
388     }
389   }
390 }
391 
InvalidateAnchor(ScheduleSelection aSchedule)392 void ScrollAnchorContainer::InvalidateAnchor(ScheduleSelection aSchedule) {
393   ANCHOR_LOG("Invalidating scroll anchor %p for %p.\n", mAnchorNode, this);
394 
395   if (mAnchorNode) {
396     SetAnchorFlags(mScrollFrame->mScrolledFrame, mAnchorNode, false);
397   } else if (mScrollFrame->mScrolledFrame->IsInScrollAnchorChain()) {
398     ANCHOR_LOG(" > Forwarding to parent anchor\n");
399     // We don't maintain an anchor, and our scrolled frame is in the anchor
400     // chain of an ancestor. Invalidate that anchor.
401     //
402     // NOTE: Intentionally not forwarding aSchedule: Scheduling is always safe
403     // and not doing so is just an optimization.
404     FindFor(Frame())->InvalidateAnchor();
405   }
406   mAnchorNode = nullptr;
407   mAnchorMightBeSubOptimal = false;
408   mAnchorNodeIsDirty = true;
409   mLastAnchorOffset = 0;
410 
411   if (!CanMaintainAnchor() || aSchedule == ScheduleSelection::No) {
412     return;
413   }
414 
415   Frame()->PresShell()->PostPendingScrollAnchorSelection(this);
416 }
417 
Destroy()418 void ScrollAnchorContainer::Destroy() {
419   InvalidateAnchor(ScheduleSelection::No);
420 }
421 
ApplyAdjustments()422 void ScrollAnchorContainer::ApplyAdjustments() {
423   if (!mAnchorNode || mAnchorNodeIsDirty || mDisabled ||
424       mScrollFrame->HasPendingScrollRestoration() ||
425       mScrollFrame->IsProcessingScrollEvent() ||
426       mScrollFrame->IsScrollAnimating() ||
427       mScrollFrame->GetScrollPosition() == nsPoint()) {
428     ANCHOR_LOG(
429         "Ignoring post-reflow (anchor=%p, dirty=%d, disabled=%d, "
430         "pendingRestoration=%d, scrollevent=%d, animating=%d, "
431         "zeroScrollPos=%d pendingSuppression=%d, "
432         "container=%p).\n",
433         mAnchorNode, mAnchorNodeIsDirty, mDisabled,
434         mScrollFrame->HasPendingScrollRestoration(),
435         mScrollFrame->IsProcessingScrollEvent(),
436         mScrollFrame->IsScrollAnimating(),
437         mScrollFrame->GetScrollPosition() == nsPoint(),
438         mSuppressAnchorAdjustment, this);
439     if (mSuppressAnchorAdjustment) {
440       mSuppressAnchorAdjustment = false;
441       InvalidateAnchor();
442     }
443     return;
444   }
445 
446   nscoord current =
447       FindScrollAnchoringBoundingOffset(mScrollFrame, mAnchorNode);
448   nscoord logicalAdjustment = current - mLastAnchorOffset;
449   WritingMode writingMode = Frame()->GetWritingMode();
450 
451   ANCHOR_LOG("Anchor has moved from %d to %d.\n", mLastAnchorOffset, current);
452 
453   auto maybeInvalidate = MakeScopeExit([&] {
454     if (mAnchorMightBeSubOptimal &&
455         StaticPrefs::layout_css_scroll_anchoring_reselect_if_suboptimal()) {
456       ANCHOR_LOG(
457           "Anchor might be suboptimal, invalidating to try finding a better "
458           "one\n");
459       InvalidateAnchor();
460     }
461   });
462 
463   if (logicalAdjustment == 0) {
464     ANCHOR_LOG("Ignoring zero delta anchor adjustment for %p.\n", this);
465     mSuppressAnchorAdjustment = false;
466     return;
467   }
468 
469   if (mSuppressAnchorAdjustment) {
470     ANCHOR_LOG("Applying anchor adjustment suppression for %p.\n", this);
471     mSuppressAnchorAdjustment = false;
472     InvalidateAnchor();
473     return;
474   }
475 
476   ANCHOR_LOG("Applying anchor adjustment of %d in %s with anchor %p.\n",
477              logicalAdjustment, ToString(writingMode).c_str(), mAnchorNode);
478 
479   AdjustmentMade(logicalAdjustment);
480 
481   nsPoint physicalAdjustment;
482   switch (writingMode.GetBlockDir()) {
483     case WritingMode::eBlockTB: {
484       physicalAdjustment.y = logicalAdjustment;
485       break;
486     }
487     case WritingMode::eBlockLR: {
488       physicalAdjustment.x = logicalAdjustment;
489       break;
490     }
491     case WritingMode::eBlockRL: {
492       physicalAdjustment.x = -logicalAdjustment;
493       break;
494     }
495   }
496 
497   MOZ_RELEASE_ASSERT(!mApplyingAnchorAdjustment);
498   // We should use AutoRestore here, but that doesn't work with bitfields
499   mApplyingAnchorAdjustment = true;
500   mScrollFrame->ScrollTo(mScrollFrame->GetScrollPosition() + physicalAdjustment,
501                          ScrollMode::Instant, ScrollOrigin::Relative);
502   mApplyingAnchorAdjustment = false;
503 
504   nsPresContext* pc = Frame()->PresContext();
505   if (mScrollFrame->mIsRoot) {
506     pc->PresShell()->RootScrollFrameAdjusted(physicalAdjustment.y);
507   }
508 
509   // The anchor position may not be in the same relative position after
510   // adjustment. Update ourselves so we have consistent state.
511   mLastAnchorOffset =
512       FindScrollAnchoringBoundingOffset(mScrollFrame, mAnchorNode);
513 }
514 
515 ScrollAnchorContainer::ExamineResult
ExamineAnchorCandidate(nsIFrame * aFrame) const516 ScrollAnchorContainer::ExamineAnchorCandidate(nsIFrame* aFrame) const {
517 #ifdef DEBUG_FRAME_DUMP
518   nsCString tag = aFrame->ListTag();
519   ANCHOR_LOG("\tVisiting frame=%s (%p).\n", tag.get(), aFrame);
520 #else
521   ANCHOR_LOG("\t\tVisiting frame=%p.\n", aFrame);
522 #endif
523   bool isText = !!Text::FromNodeOrNull(aFrame->GetContent());
524   bool isContinuation = !!aFrame->GetPrevContinuation();
525 
526   if (isText && isContinuation) {
527     ANCHOR_LOG("\t\tExcluding continuation text node.\n");
528     return ExamineResult::Exclude;
529   }
530 
531   // Check if the author has opted out of scroll anchoring for this frame
532   // and its descendants.
533   const nsStyleDisplay* disp = aFrame->StyleDisplay();
534   if (disp->mOverflowAnchor == mozilla::StyleOverflowAnchor::None) {
535     ANCHOR_LOG("\t\tExcluding `overflow-anchor: none`.\n");
536     return ExamineResult::Exclude;
537   }
538 
539   // Sticky positioned elements can move with the scroll frame, making them
540   // unsuitable scroll anchors. This isn't in the specification yet [1], but
541   // matches Blink's implementation.
542   //
543   // [1] https://github.com/w3c/csswg-drafts/issues/3319
544   if (aFrame->IsStickyPositioned()) {
545     ANCHOR_LOG("\t\tExcluding `position: sticky`.\n");
546     return ExamineResult::Exclude;
547   }
548 
549   // The frame for a <br> element has a non-zero area, but Blink treats them
550   // as if they have no area, so exclude them specially.
551   if (aFrame->IsBrFrame()) {
552     ANCHOR_LOG("\t\tExcluding <br>.\n");
553     return ExamineResult::Exclude;
554   }
555 
556   // Exclude frames that aren't accessible to content.
557   bool isChrome =
558       aFrame->GetContent() && aFrame->GetContent()->ChromeOnlyAccess();
559   bool isPseudo = aFrame->Style()->IsPseudoElement();
560   if (isChrome && !isPseudo) {
561     ANCHOR_LOG("\t\tExcluding chrome only content.\n");
562     return ExamineResult::Exclude;
563   }
564 
565   const bool isReplaced = aFrame->IsFrameOfType(nsIFrame::eReplaced);
566 
567   const bool isNonReplacedInline =
568       aFrame->StyleDisplay()->IsInlineInsideStyle() && !isReplaced;
569 
570   const bool isAnonBox = aFrame->Style()->IsAnonBox();
571 
572   // See if this frame has or could maintain its own anchor node.
573   const bool isScrollableWithAnchor = [&] {
574     nsIScrollableFrame* scrollable = do_QueryFrame(aFrame);
575     if (!scrollable) {
576       return false;
577     }
578     auto* anchor = scrollable->Anchor();
579     return anchor->AnchorNode() || anchor->CanMaintainAnchor();
580   }();
581 
582   // We don't allow scroll anchors to be selected inside of nested scrollable
583   // frames which maintain an anchor node as it's not clear how an anchor
584   // adjustment should apply to multiple scrollable frames.
585   //
586   // It is important to descend into _some_ scrollable frames, specially
587   // overflow: hidden, as those don't generally maintain their own anchors, and
588   // it is a common case in the wild where scroll anchoring ought to work.
589   //
590   // We also don't allow scroll anchors to be selected inside of replaced
591   // elements (like <img>, <video>, <svg>...) as they behave atomically. SVG
592   // uses a different layout model than CSS, and the specification doesn't say
593   // it should apply anyway.
594   //
595   // [1] https://github.com/w3c/csswg-drafts/issues/3477
596   const bool canDescend = !isScrollableWithAnchor && !isReplaced;
597 
598   // Non-replaced inline boxes (including ruby frames) and anon boxes are not
599   // acceptable anchors, so we descend if possible, or otherwise exclude them
600   // altogether.
601   if (!isText && (isNonReplacedInline || isAnonBox)) {
602     ANCHOR_LOG(
603         "\t\tSearching descendants of anon or non-replaced inline box (a=%d, "
604         "i=%d).\n",
605         isAnonBox, isNonReplacedInline);
606     if (canDescend) {
607       return ExamineResult::PassThrough;
608     }
609     return ExamineResult::Exclude;
610   }
611 
612   // Find the scroll anchoring bounding rect.
613   nsRect rect = FindScrollAnchoringBoundingRect(Frame(), aFrame);
614   ANCHOR_LOG("\t\trect = [%d %d x %d %d].\n", rect.x, rect.y, rect.width,
615              rect.height);
616 
617   // Check if this frame is visible in the scroll port. This will exclude rects
618   // with zero sized area. The specification is ambiguous about this [1], but
619   // this matches Blink's implementation.
620   //
621   // [1] https://github.com/w3c/csswg-drafts/issues/3483
622   nsRect visibleRect;
623   if (!visibleRect.IntersectRect(rect,
624                                  mScrollFrame->GetVisualOptimalViewingRect())) {
625     return ExamineResult::Exclude;
626   }
627 
628   // It's not clear what the scroll anchoring bounding rect is, for elements
629   // fragmented in the block direction (e.g. across column or page breaks).
630   //
631   // Inline-fragmented elements other than text shouldn't get here because of
632   // the isNonReplacedInline check.
633   //
634   // For text nodes that are fragmented, it's specified that we need to consider
635   // the union of its line boxes.
636   //
637   // So for text nodes we handle them by including the union of line boxes in
638   // the bounding rect of the primary frame, and not selecting any
639   // continuations.
640   //
641   // For block-outside elements we choose to consider the bounding rect of each
642   // frame individually, allowing ourselves to descend into any frame, but only
643   // selecting a frame if it's not a continuation.
644   if (canDescend && isContinuation) {
645     ANCHOR_LOG("\t\tSearching descendants of a continuation.\n");
646     return ExamineResult::PassThrough;
647   }
648 
649   // If this frame is fully visible, then select it as the scroll anchor.
650   if (visibleRect.IsEqualEdges(rect)) {
651     ANCHOR_LOG("\t\tFully visible, taking.\n");
652     return ExamineResult::Accept;
653   }
654 
655   // If we can't descend into this frame, then select it as the scroll anchor.
656   if (!canDescend) {
657     ANCHOR_LOG("\t\tIntersects a frame that we can't descend into, taking.\n");
658     return ExamineResult::Accept;
659   }
660 
661   // It must be partially visible and we can descend into this frame. Examine
662   // its children for a better scroll anchor or fall back to this one.
663   ANCHOR_LOG("\t\tIntersects valid candidate, checking descendants.\n");
664   return ExamineResult::Traverse;
665 }
666 
FindAnchorIn(nsIFrame * aFrame) const667 nsIFrame* ScrollAnchorContainer::FindAnchorIn(nsIFrame* aFrame) const {
668   // Visit the child lists of this frame
669   for (const auto& [list, listID] : aFrame->ChildLists()) {
670     // Skip child lists that contain out-of-flow frames, we'll visit them by
671     // following placeholders in the in-flow lists so that we visit these
672     // frames in DOM order.
673     // XXX do we actually need to exclude kOverflowOutOfFlowList too?
674     if (listID == FrameChildListID::kAbsoluteList ||
675         listID == FrameChildListID::kFixedList ||
676         listID == FrameChildListID::kFloatList ||
677         listID == FrameChildListID::kOverflowOutOfFlowList) {
678       continue;
679     }
680 
681     // Search the child list, and return if we selected an anchor
682     if (nsIFrame* anchor = FindAnchorInList(list)) {
683       return anchor;
684     }
685   }
686 
687   // The spec requires us to do an extra pass to visit absolutely positioned
688   // frames a second time after all the children of their containing block have
689   // been visited.
690   //
691   // It's not clear why this is needed [1], but it matches Blink's
692   // implementation, and is needed for a WPT test.
693   //
694   // [1] https://github.com/w3c/csswg-drafts/issues/3465
695   const nsFrameList& absPosList =
696       aFrame->GetChildList(FrameChildListID::kAbsoluteList);
697   if (nsIFrame* anchor = FindAnchorInList(absPosList)) {
698     return anchor;
699   }
700 
701   return nullptr;
702 }
703 
FindAnchorInList(const nsFrameList & aFrameList) const704 nsIFrame* ScrollAnchorContainer::FindAnchorInList(
705     const nsFrameList& aFrameList) const {
706   for (nsIFrame* child : aFrameList) {
707     // If this is a placeholder, try to follow it to the out of flow frame.
708     nsIFrame* realFrame = nsPlaceholderFrame::GetRealFrameFor(child);
709     if (child != realFrame) {
710       // If the out of flow frame is not a descendant of our scroll frame,
711       // then it must have a different containing block and cannot be an
712       // anchor node.
713       if (!nsLayoutUtils::IsProperAncestorFrame(Frame(), realFrame)) {
714         ANCHOR_LOG(
715             "\t\tSkipping out of flow frame that is not a descendant of the "
716             "scroll frame.\n");
717         continue;
718       }
719       ANCHOR_LOG("\t\tFollowing placeholder to out of flow frame.\n");
720       child = realFrame;
721     }
722 
723     // Perform the candidate examination algorithm
724     ExamineResult examine = ExamineAnchorCandidate(child);
725 
726     // See the comment before the definition of `ExamineResult` in
727     // `ScrollAnchorContainer.h` for an explanation of this behavior.
728     switch (examine) {
729       case ExamineResult::Exclude: {
730         continue;
731       }
732       case ExamineResult::PassThrough: {
733         nsIFrame* candidate = FindAnchorIn(child);
734         if (!candidate) {
735           continue;
736         }
737         return candidate;
738       }
739       case ExamineResult::Traverse: {
740         nsIFrame* candidate = FindAnchorIn(child);
741         if (!candidate) {
742           return child;
743         }
744         return candidate;
745       }
746       case ExamineResult::Accept: {
747         return child;
748       }
749     }
750   }
751   return nullptr;
752 }
753 
754 }  // namespace layout
755 }  // namespace mozilla
756