tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

DoubleTapToZoom.cpp (18290B)


      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 "DoubleTapToZoom.h"
      8 
      9 #include <algorithm>  // for std::min, std::max
     10 
     11 #include "mozilla/PresShell.h"
     12 #include "mozilla/ScrollContainerFrame.h"
     13 #include "mozilla/AlreadyAddRefed.h"
     14 #include "mozilla/dom/Element.h"
     15 #include "mozilla/dom/EffectsInfo.h"
     16 #include "mozilla/dom/BrowserChild.h"
     17 #include "nsCOMPtr.h"
     18 #include "nsIContent.h"
     19 #include "mozilla/dom/Document.h"
     20 #include "nsIFrame.h"
     21 #include "nsIFrameInlines.h"
     22 #include "nsTableCellFrame.h"
     23 #include "nsLayoutUtils.h"
     24 #include "nsStyleConsts.h"
     25 #include "mozilla/ViewportUtils.h"
     26 #include "mozilla/EventListenerManager.h"
     27 #include "mozilla/layers/APZUtils.h"
     28 
     29 namespace mozilla {
     30 namespace layers {
     31 
     32 namespace {
     33 
     34 using FrameForPointOption = nsLayoutUtils::FrameForPointOption;
     35 
     36 static bool IsGeneratedContent(nsIContent* aContent) {
     37  // We exclude marks because making them double tap targets does not seem
     38  // desirable.
     39  return aContent->IsGeneratedContentContainerForBefore() ||
     40         aContent->IsGeneratedContentContainerForAfter();
     41 }
     42 
     43 // Returns the DOM element found at |aPoint|, interpreted as being relative to
     44 // the root frame of |aPresShell| in visual coordinates. If the point is inside
     45 // a subdocument, returns an element inside the subdocument, rather than the
     46 // subdocument element (and does so recursively). The implementation was adapted
     47 // from DocumentOrShadowRoot::ElementFromPoint(), with the notable exception
     48 // that we don't pass nsLayoutUtils::IGNORE_CROSS_DOC to GetFrameForPoint(), so
     49 // as to get the behaviour described above in the presence of subdocuments.
     50 static already_AddRefed<dom::Element> ElementFromPoint(
     51    const RefPtr<PresShell>& aPresShell, const CSSPoint& aPoint) {
     52  nsIFrame* rootFrame = aPresShell->GetRootFrame();
     53  if (!rootFrame) {
     54    return nullptr;
     55  }
     56  nsIFrame* frame = nsLayoutUtils::GetFrameForPoint(
     57      RelativeTo{rootFrame, ViewportType::Visual}, CSSPoint::ToAppUnits(aPoint),
     58      {{FrameForPointOption::IgnorePaintSuppression}});
     59  while (frame && (!frame->GetContent() ||
     60                   (frame->GetContent()->IsInNativeAnonymousSubtree() &&
     61                    !IsGeneratedContent(frame->GetContent())))) {
     62    frame = nsLayoutUtils::GetParentOrPlaceholderFor(frame);
     63  }
     64  if (!frame) {
     65    return nullptr;
     66  }
     67  // FIXME(emilio): This should probably use the flattened tree, GetParent() is
     68  // not guaranteed to be an element in presence of shadow DOM.
     69  nsIContent* content = frame->GetContent();
     70  if (!content) {
     71    return nullptr;
     72  }
     73  if (dom::Element* element = content->GetAsElementOrParentElement()) {
     74    return do_AddRef(element);
     75  }
     76  return nullptr;
     77 }
     78 
     79 // Get table cell from element, parent or grand parent.
     80 static dom::Element* GetNearbyTableCell(
     81    const nsCOMPtr<dom::Element>& aElement) {
     82  nsTableCellFrame* tableCell = do_QueryFrame(aElement->GetPrimaryFrame());
     83  if (tableCell) {
     84    return aElement.get();
     85  }
     86  if (dom::Element* parent = aElement->GetFlattenedTreeParentElement()) {
     87    nsTableCellFrame* tableCell = do_QueryFrame(parent->GetPrimaryFrame());
     88    if (tableCell) {
     89      return parent;
     90    }
     91    if (dom::Element* grandParent = parent->GetFlattenedTreeParentElement()) {
     92      tableCell = do_QueryFrame(grandParent->GetPrimaryFrame());
     93      if (tableCell) {
     94        return grandParent;
     95      }
     96    }
     97  }
     98  return nullptr;
     99 }
    100 
    101 // A utility function returns the given |aElement| rectangle relative to the top
    102 // level content document coordinates.
    103 static CSSRect GetBoundingContentRect(
    104    const dom::Element* aElement,
    105    const RefPtr<dom::Document>& aInProcessRootContentDocument,
    106    const ScrollContainerFrame* aRootScrollContainerFrame,
    107    const DoubleTapToZoomMetrics& aMetrics,
    108    mozilla::Maybe<CSSRect>* aOutNearestScrollClip = nullptr) {
    109  CSSRect result = nsLayoutUtils::GetBoundingContentRect(
    110      aElement, aRootScrollContainerFrame, aOutNearestScrollClip);
    111  if (aInProcessRootContentDocument->IsTopLevelContentDocument()) {
    112    return result;
    113  }
    114 
    115  nsIFrame* frame = aElement->GetPrimaryFrame();
    116  if (!frame) {
    117    return CSSRect();
    118  }
    119 
    120  // If the nearest scroll container frame is |aRootScrollContainerFrame|,
    121  // nsLayoutUtils::GetBoundingContentRect doesn't set |aOutNearestScrollClip|,
    122  // thus in the cases of OOP iframs, we need to use the visible rect of the
    123  // iframe as the nearest scroll clip.
    124  if (aOutNearestScrollClip && aOutNearestScrollClip->isNothing()) {
    125    if (dom::BrowserChild* browserChild =
    126            dom::BrowserChild::GetFrom(frame->PresShell())) {
    127      const dom::EffectsInfo& effectsInfo = browserChild->GetEffectsInfo();
    128      if (effectsInfo.IsVisible()) {
    129        *aOutNearestScrollClip =
    130            effectsInfo.mVisibleRect.map([&aMetrics](const nsRect& aRect) {
    131              return aMetrics.mTransformMatrix.TransformBounds(
    132                  CSSRect::FromAppUnits(aRect));
    133            });
    134      }
    135    }
    136  }
    137 
    138  // In the case of an element inside an OOP iframe, |aMetrics.mTransformMatrix|
    139  // includes the translation information about the root layout scroll offset,
    140  // thus we use nsIFrame::GetBoundingClientRect rather than
    141  // nsLayoutUtils::GetBoundingContent.
    142  return aMetrics.mTransformMatrix.TransformBounds(
    143      CSSRect::FromAppUnits(frame->GetBoundingClientRect()));
    144 }
    145 
    146 static bool ShouldZoomToElement(
    147    const nsCOMPtr<dom::Element>& aElement,
    148    const RefPtr<dom::Document>& aInProcessRootContentDocument,
    149    ScrollContainerFrame* aRootScrollContainerFrame,
    150    const DoubleTapToZoomMetrics& aMetrics) {
    151  if (nsIFrame* frame = aElement->GetPrimaryFrame()) {
    152    if (frame->StyleDisplay()->IsInlineFlow() &&
    153        // Replaced elements are suitable zoom targets because they act like
    154        // inline-blocks instead of inline. (textarea's are the specific reason
    155        // we do this)
    156        !frame->IsReplaced()) {
    157      return false;
    158    }
    159  }
    160  // Trying to zoom to the html element will just end up scrolling to the start
    161  // of the document, return false and we'll run out of elements and just
    162  // zoomout (without scrolling to the start).
    163  if (aElement->OwnerDoc() == aInProcessRootContentDocument &&
    164      aElement->IsHTMLElement(nsGkAtoms::html)) {
    165    return false;
    166  }
    167  if (aElement->IsAnyOfHTMLElements(nsGkAtoms::li, nsGkAtoms::q)) {
    168    return false;
    169  }
    170 
    171  // Ignore elements who are table cells or their parents are table cells, and
    172  // they take up less than 30% of page rect width because they are likely cells
    173  // in data tables (as opposed to tables used for layout purposes), and we
    174  // don't want to zoom to them. This heuristic is quite naive and leaves a lot
    175  // to be desired.
    176  if (dom::Element* tableCell = GetNearbyTableCell(aElement)) {
    177    CSSRect rect =
    178        GetBoundingContentRect(tableCell, aInProcessRootContentDocument,
    179                               aRootScrollContainerFrame, aMetrics);
    180    if (rect.width < 0.3 * aMetrics.mRootScrollableRect.width) {
    181      return false;
    182    }
    183  }
    184 
    185  return true;
    186 }
    187 
    188 // Calculates if zooming to aRect would have almost the same zoom level as
    189 // aCompositedArea currently has. If so we would want to zoom out instead.
    190 static bool RectHasAlmostSameZoomLevel(const CSSRect& aRect,
    191                                       const CSSRect& aCompositedArea) {
    192  // This functions checks to see if the area of the rect visible in the
    193  // composition bounds (i.e. the overlapArea variable below) is approximately
    194  // the max area of the rect we can show.
    195 
    196  // AsyncPanZoomController::ZoomToRect will adjust the zoom and scroll offset
    197  // so that the zoom to rect fills the composited area. If after adjusting the
    198  // scroll offset _only_ the rect would fill the composited area we want to
    199  // zoom out (we don't want to _just_ scroll, we want to do some amount of
    200  // zooming, either in or out it doesn't matter which). So translate both rects
    201  // to the same origin and then compute their overlap, which is what the
    202  // following calculation does.
    203 
    204  float overlapArea = std::min(aRect.width, aCompositedArea.width) *
    205                      std::min(aRect.height, aCompositedArea.height);
    206  float availHeight = std::min(
    207      aRect.Width() * aCompositedArea.Height() / aCompositedArea.Width(),
    208      aRect.Height());
    209  float showing = overlapArea / (aRect.Width() * availHeight);
    210  float ratioW = aRect.Width() / aCompositedArea.Width();
    211  float ratioH = aRect.Height() / aCompositedArea.Height();
    212 
    213  return showing > 0.9 && (ratioW > 0.9 || ratioH > 0.9);
    214 }
    215 
    216 }  // namespace
    217 
    218 static CSSRect AddHMargin(const CSSRect& aRect, const CSSCoord& aMargin,
    219                          const CSSRect& aRootScrollableRect) {
    220  CSSRect rect =
    221      CSSRect(std::max(aRootScrollableRect.X(), aRect.X() - aMargin), aRect.Y(),
    222              aRect.Width() + 2 * aMargin, aRect.Height());
    223  // Constrict the rect to the screen's right edge
    224  rect.SetWidth(std::min(rect.Width(), aRootScrollableRect.XMost() - rect.X()));
    225  return rect;
    226 }
    227 
    228 static CSSRect AddVMargin(const CSSRect& aRect, const CSSCoord& aMargin,
    229                          const CSSRect& aRootScrollableRect) {
    230  CSSRect rect =
    231      CSSRect(aRect.X(), std::max(aRootScrollableRect.Y(), aRect.Y() - aMargin),
    232              aRect.Width(), aRect.Height() + 2 * aMargin);
    233  // Constrict the rect to the screen's bottom edge
    234  rect.SetHeight(
    235      std::min(rect.Height(), aRootScrollableRect.YMost() - rect.Y()));
    236  return rect;
    237 }
    238 
    239 static bool IsReplacedElement(const nsCOMPtr<dom::Element>& aElement) {
    240  if (nsIFrame* frame = aElement->GetPrimaryFrame()) {
    241    if (frame->IsReplaced()) {
    242      return true;
    243    }
    244  }
    245  return false;
    246 }
    247 
    248 static bool HasNonPassiveWheelListenerOnAncestor(nsIContent* aContent) {
    249  for (nsIContent* content = aContent; content;
    250       content = content->GetFlattenedTreeParent()) {
    251    EventListenerManager* elm = content->GetExistingListenerManager();
    252    if (elm && elm->HasNonPassiveWheelListener()) {
    253      return true;
    254    }
    255  }
    256  return false;
    257 }
    258 
    259 ZoomTarget CalculateRectToZoomTo(
    260    const RefPtr<dom::Document>& aInProcessRootContentDocument,
    261    const CSSPoint& aPoint, const DoubleTapToZoomMetrics& aMetrics) {
    262  // Ensure the layout information we get is up-to-date.
    263  aInProcessRootContentDocument->FlushPendingNotifications(FlushType::Layout);
    264 
    265  // An empty rect as return value is interpreted as "zoom out".
    266  const CSSRect zoomOut;
    267 
    268  RefPtr<PresShell> presShell = aInProcessRootContentDocument->GetPresShell();
    269  if (!presShell) {
    270    return ZoomTarget{zoomOut, CantZoomOutBehavior::ZoomIn};
    271  }
    272 
    273  ScrollContainerFrame* rootScrollContainerFrame =
    274      presShell->GetRootScrollContainerFrame();
    275  if (!rootScrollContainerFrame) {
    276    return ZoomTarget{zoomOut, CantZoomOutBehavior::ZoomIn};
    277  }
    278 
    279  CSSPoint documentRelativePoint =
    280      aInProcessRootContentDocument->IsTopLevelContentDocument()
    281          ? CSSPoint::FromAppUnits(ViewportUtils::VisualToLayout(
    282                CSSPoint::ToAppUnits(aPoint), presShell)) +
    283                CSSPoint::FromAppUnits(
    284                    rootScrollContainerFrame->GetScrollPosition())
    285          : aMetrics.mTransformMatrix.TransformPoint(aPoint);
    286 
    287  nsCOMPtr<dom::Element> element = ElementFromPoint(presShell, aPoint);
    288  if (!element) {
    289    return ZoomTarget{zoomOut, CantZoomOutBehavior::ZoomIn, Nothing(),
    290                      Some(documentRelativePoint)};
    291  }
    292 
    293  CantZoomOutBehavior cantZoomOutBehavior =
    294      HasNonPassiveWheelListenerOnAncestor(element)
    295          ? CantZoomOutBehavior::Nothing
    296          : CantZoomOutBehavior::ZoomIn;
    297 
    298  while (element && !ShouldZoomToElement(element, aInProcessRootContentDocument,
    299                                         rootScrollContainerFrame, aMetrics)) {
    300    element = element->GetFlattenedTreeParentElement();
    301  }
    302 
    303  if (!element) {
    304    return ZoomTarget{zoomOut, cantZoomOutBehavior, Nothing(),
    305                      Some(documentRelativePoint)};
    306  }
    307 
    308  Maybe<CSSRect> nearestScrollClip;
    309  CSSRect rect = GetBoundingContentRect(element, aInProcessRootContentDocument,
    310                                        rootScrollContainerFrame, aMetrics,
    311                                        &nearestScrollClip);
    312 
    313  // In some cases, like overflow: visible and overflowing content, the bounding
    314  // client rect of the targeted element won't contain the point the user double
    315  // tapped on. In that case we use the scrollable overflow rect if it contains
    316  // the user point.
    317  if (!rect.Contains(documentRelativePoint)) {
    318    if (nsIFrame* scrolledFrame =
    319            rootScrollContainerFrame->GetScrolledFrame()) {
    320      if (nsIFrame* f = element->GetPrimaryFrame()) {
    321        nsRect overflowRect = f->ScrollableOverflowRect();
    322        nsLayoutUtils::TransformResult res =
    323            nsLayoutUtils::TransformRect(f, scrolledFrame, overflowRect);
    324        MOZ_ASSERT(res == nsLayoutUtils::TRANSFORM_SUCCEEDED ||
    325                   res == nsLayoutUtils::NONINVERTIBLE_TRANSFORM);
    326        if (res == nsLayoutUtils::TRANSFORM_SUCCEEDED) {
    327          CSSRect overflowRectCSS = CSSRect::FromAppUnits(overflowRect);
    328 
    329          // In the case of OOP iframes, above |overflowRectCSS| in the iframe
    330          // documents coords, we need to convert it into the top level coords.
    331          if (!aInProcessRootContentDocument->IsTopLevelContentDocument()) {
    332            overflowRectCSS.MoveBy(CSSPoint::FromAppUnits(
    333                -rootScrollContainerFrame->GetScrollPosition()));
    334            overflowRectCSS =
    335                aMetrics.mTransformMatrix.TransformBounds(overflowRectCSS);
    336          }
    337          if (nearestScrollClip.isSome()) {
    338            overflowRectCSS = nearestScrollClip->Intersect(overflowRectCSS);
    339          }
    340          if (overflowRectCSS.Contains(documentRelativePoint)) {
    341            rect = overflowRectCSS;
    342          }
    343        }
    344      }
    345    }
    346  }
    347 
    348  CSSRect elementBoundingRect = rect;
    349 
    350  // Generally we zoom to the width of some element, but sometimes we zoom to
    351  // the height. We set this to true when that happens so that we can add a
    352  // vertical margin to the rect, otherwise it looks weird.
    353  bool heightConstrained = false;
    354 
    355  // If the element is taller than the visible area of the page scale
    356  // the height of the |rect| so that it has the same aspect ratio as
    357  // the root frame.  The clipped |rect| is centered on the y value of
    358  // the touch point. This allows tall narrow elements to be zoomed.
    359  if (!rect.IsEmpty() && aMetrics.mVisualViewport.Width() > 0.0f &&
    360      aMetrics.mVisualViewport.Height() > 0.0f) {
    361    // Calculate the height of the rect if it had the same aspect ratio as
    362    // aMetrics.mVisualViewport.
    363    const float widthRatio = rect.Width() / aMetrics.mVisualViewport.Width();
    364    float targetHeight = aMetrics.mVisualViewport.Height() * widthRatio;
    365 
    366    // We don't want to cut off the top or bottoms of replaced elements that are
    367    // square or wider in aspect ratio.
    368 
    369    // If it's a replaced element and we would otherwise trim it's height below
    370    if (IsReplacedElement(element) && targetHeight < rect.Height() &&
    371        // If the target rect is at most 1.1x away from being square or wider
    372        // aspect ratio
    373        rect.Height() < 1.1 * rect.Width() &&
    374        // and our aMetrics.mVisualViewport is wider than it is tall
    375        aMetrics.mVisualViewport.Width() >= aMetrics.mVisualViewport.Height()) {
    376      heightConstrained = true;
    377      // Expand the width of the rect so that it fills aMetrics.mVisualViewport
    378      // so that if we are already zoomed to this element then the
    379      // IsRectZoomedIn call below returns true so that we zoom out. This won't
    380      // change what we actually zoom to as we are just making the rect the same
    381      // aspect ratio as aMetrics.mVisualViewport.
    382      float targetWidth = rect.Height() * aMetrics.mVisualViewport.Width() /
    383                          aMetrics.mVisualViewport.Height();
    384      MOZ_ASSERT(targetWidth > rect.Width());
    385      if (targetWidth > rect.Width()) {
    386        rect.x -= (targetWidth - rect.Width()) / 2;
    387        rect.SetWidth(targetWidth);
    388        // keep elementBoundingRect containing rect
    389        elementBoundingRect = rect;
    390      }
    391 
    392    } else if (targetHeight < rect.Height()) {
    393      // Trim the height so that the target rect has the same aspect ratio as
    394      // aMetrics.mVisualViewport, centering it around the user tap point.
    395      float newY = documentRelativePoint.y - (targetHeight * 0.5f);
    396      if ((newY + targetHeight) > rect.YMost()) {
    397        rect.MoveByY(rect.Height() - targetHeight);
    398      } else if (newY > rect.Y()) {
    399        rect.MoveToY(newY);
    400      }
    401      rect.SetHeight(targetHeight);
    402    }
    403  }
    404 
    405  const CSSCoord margin = 15;
    406  rect = AddHMargin(rect, margin, aMetrics.mRootScrollableRect);
    407 
    408  if (heightConstrained) {
    409    rect = AddVMargin(rect, margin, aMetrics.mRootScrollableRect);
    410  }
    411 
    412  // If the rect is already taking up most of the visible area and is
    413  // stretching the width of the page, then we want to zoom out instead.
    414  if (RectHasAlmostSameZoomLevel(rect, aMetrics.mVisualViewport)) {
    415    return ZoomTarget{zoomOut, cantZoomOutBehavior, Nothing(),
    416                      Some(documentRelativePoint)};
    417  }
    418 
    419  elementBoundingRect =
    420      AddHMargin(elementBoundingRect, margin, aMetrics.mRootScrollableRect);
    421 
    422  // Unlike rect, elementBoundingRect is the full height of the element we are
    423  // zooming to. If we zoom to it without a margin it can look a weird, so give
    424  // it a vertical margin.
    425  elementBoundingRect =
    426      AddVMargin(elementBoundingRect, margin, aMetrics.mRootScrollableRect);
    427 
    428  rect.Round();
    429  elementBoundingRect.Round();
    430 
    431  return ZoomTarget{rect, cantZoomOutBehavior, Some(elementBoundingRect),
    432                    Some(documentRelativePoint)};
    433 }
    434 
    435 std::ostream& operator<<(std::ostream& aStream,
    436                         const DoubleTapToZoomMetrics& aMetrics) {
    437  aStream << "{ vv=" << aMetrics.mVisualViewport
    438          << ", rscr=" << aMetrics.mRootScrollableRect
    439          << ", transform=" << aMetrics.mTransformMatrix << " }";
    440  return aStream;
    441 }
    442 
    443 }  // namespace layers
    444 }  // namespace mozilla