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