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