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