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