summaryrefslogtreecommitdiffstats
path: root/gfx/layers/apz/util/DoubleTapToZoom.cpp
blob: cf64a310b7639ea5338936b4ed68b75d50bb2d51 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=8 sts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

#include "DoubleTapToZoom.h"

#include <algorithm>  // for std::min, std::max

#include "mozilla/PresShell.h"
#include "mozilla/AlreadyAddRefed.h"
#include "mozilla/dom/Element.h"
#include "mozilla/dom/EffectsInfo.h"
#include "mozilla/dom/BrowserChild.h"
#include "nsCOMPtr.h"
#include "nsIContent.h"
#include "mozilla/dom/Document.h"
#include "nsIFrame.h"
#include "nsIFrameInlines.h"
#include "nsIScrollableFrame.h"
#include "nsTableCellFrame.h"
#include "nsLayoutUtils.h"
#include "nsStyleConsts.h"
#include "mozilla/ViewportUtils.h"
#include "mozilla/EventListenerManager.h"
#include "mozilla/layers/APZUtils.h"

namespace mozilla {
namespace layers {

namespace {

using FrameForPointOption = nsLayoutUtils::FrameForPointOption;

static bool IsGeneratedContent(nsIContent* aContent) {
  // We exclude marks because making them double tap targets does not seem
  // desirable.
  return aContent->IsGeneratedContentContainerForBefore() ||
         aContent->IsGeneratedContentContainerForAfter();
}

// Returns the DOM element found at |aPoint|, interpreted as being relative to
// the root frame of |aPresShell| in visual coordinates. If the point is inside
// a subdocument, returns an element inside the subdocument, rather than the
// subdocument element (and does so recursively). The implementation was adapted
// from DocumentOrShadowRoot::ElementFromPoint(), with the notable exception
// that we don't pass nsLayoutUtils::IGNORE_CROSS_DOC to GetFrameForPoint(), so
// as to get the behaviour described above in the presence of subdocuments.
static already_AddRefed<dom::Element> ElementFromPoint(
    const RefPtr<PresShell>& aPresShell, const CSSPoint& aPoint) {
  nsIFrame* rootFrame = aPresShell->GetRootFrame();
  if (!rootFrame) {
    return nullptr;
  }
  nsIFrame* frame = nsLayoutUtils::GetFrameForPoint(
      RelativeTo{rootFrame, ViewportType::Visual}, CSSPoint::ToAppUnits(aPoint),
      {{FrameForPointOption::IgnorePaintSuppression}});
  while (frame && (!frame->GetContent() ||
                   (frame->GetContent()->IsInNativeAnonymousSubtree() &&
                    !IsGeneratedContent(frame->GetContent())))) {
    frame = nsLayoutUtils::GetParentOrPlaceholderFor(frame);
  }
  if (!frame) {
    return nullptr;
  }
  // FIXME(emilio): This should probably use the flattened tree, GetParent() is
  // not guaranteed to be an element in presence of shadow DOM.
  nsIContent* content = frame->GetContent();
  if (!content) {
    return nullptr;
  }
  if (dom::Element* element = content->GetAsElementOrParentElement()) {
    return do_AddRef(element);
  }
  return nullptr;
}

// Get table cell from element, parent or grand parent.
static dom::Element* GetNearbyTableCell(
    const nsCOMPtr<dom::Element>& aElement) {
  nsTableCellFrame* tableCell = do_QueryFrame(aElement->GetPrimaryFrame());
  if (tableCell) {
    return aElement.get();
  }
  if (dom::Element* parent = aElement->GetFlattenedTreeParentElement()) {
    nsTableCellFrame* tableCell = do_QueryFrame(parent->GetPrimaryFrame());
    if (tableCell) {
      return parent;
    }
    if (dom::Element* grandParent = parent->GetFlattenedTreeParentElement()) {
      tableCell = do_QueryFrame(grandParent->GetPrimaryFrame());
      if (tableCell) {
        return grandParent;
      }
    }
  }
  return nullptr;
}

// A utility function returns the given |aElement| rectangle relative to the top
// level content document coordinates.
static CSSRect GetBoundingContentRect(
    const dom::Element* aElement,
    const RefPtr<dom::Document>& aInProcessRootContentDocument,
    const nsIScrollableFrame* aRootScrollFrame,
    const DoubleTapToZoomMetrics& aMetrics,
    mozilla::Maybe<CSSRect>* aOutNearestScrollClip = nullptr) {
  CSSRect result = nsLayoutUtils::GetBoundingContentRect(
      aElement, aRootScrollFrame, aOutNearestScrollClip);
  if (aInProcessRootContentDocument->IsTopLevelContentDocument()) {
    return result;
  }

  nsIFrame* frame = aElement->GetPrimaryFrame();
  if (!frame) {
    return CSSRect();
  }

  // If the nearest scroll frame is |aRootScrollFrame|,
  // nsLayoutUtils::GetBoundingContentRect doesn't set |aOutNearestScrollClip|,
  // thus in the cases of OOP iframs, we need to use the visible rect of the
  // iframe as the nearest scroll clip.
  if (aOutNearestScrollClip && aOutNearestScrollClip->isNothing()) {
    if (dom::BrowserChild* browserChild =
            dom::BrowserChild::GetFrom(frame->PresShell())) {
      const dom::EffectsInfo& effectsInfo = browserChild->GetEffectsInfo();
      if (effectsInfo.IsVisible()) {
        *aOutNearestScrollClip =
            effectsInfo.mVisibleRect.map([&aMetrics](const nsRect& aRect) {
              return aMetrics.mTransformMatrix.TransformBounds(
                  CSSRect::FromAppUnits(aRect));
            });
      }
    }
  }

  // In the case of an element inside an OOP iframe, |aMetrics.mTransformMatrix|
  // includes the translation information about the root layout scroll offset,
  // thus we use nsIFrame::GetBoundingClientRect rather than
  // nsLayoutUtils::GetBoundingContent.
  return aMetrics.mTransformMatrix.TransformBounds(
      CSSRect::FromAppUnits(frame->GetBoundingClientRect()));
}

static bool ShouldZoomToElement(
    const nsCOMPtr<dom::Element>& aElement,
    const RefPtr<dom::Document>& aInProcessRootContentDocument,
    nsIScrollableFrame* aRootScrollFrame,
    const DoubleTapToZoomMetrics& aMetrics) {
  if (nsIFrame* frame = aElement->GetPrimaryFrame()) {
    if (frame->StyleDisplay()->IsInlineFlow() &&
        // Replaced elements are suitable zoom targets because they act like
        // inline-blocks instead of inline. (textarea's are the specific reason
        // we do this)
        !frame->IsReplaced()) {
      return false;
    }
  }
  // Trying to zoom to the html element will just end up scrolling to the start
  // of the document, return false and we'll run out of elements and just
  // zoomout (without scrolling to the start).
  if (aElement->OwnerDoc() == aInProcessRootContentDocument &&
      aElement->IsHTMLElement(nsGkAtoms::html)) {
    return false;
  }
  if (aElement->IsAnyOfHTMLElements(nsGkAtoms::li, nsGkAtoms::q)) {
    return false;
  }

  // Ignore elements who are table cells or their parents are table cells, and
  // they take up less than 30% of page rect width because they are likely cells
  // in data tables (as opposed to tables used for layout purposes), and we
  // don't want to zoom to them. This heuristic is quite naive and leaves a lot
  // to be desired.
  if (dom::Element* tableCell = GetNearbyTableCell(aElement)) {
    CSSRect rect = GetBoundingContentRect(
        tableCell, aInProcessRootContentDocument, aRootScrollFrame, aMetrics);
    if (rect.width < 0.3 * aMetrics.mRootScrollableRect.width) {
      return false;
    }
  }

  return true;
}

// Calculates if zooming to aRect would have almost the same zoom level as
// aCompositedArea currently has. If so we would want to zoom out instead.
static bool RectHasAlmostSameZoomLevel(const CSSRect& aRect,
                                       const CSSRect& aCompositedArea) {
  // This functions checks to see if the area of the rect visible in the
  // composition bounds (i.e. the overlapArea variable below) is approximately
  // the max area of the rect we can show.

  // AsyncPanZoomController::ZoomToRect will adjust the zoom and scroll offset
  // so that the zoom to rect fills the composited area. If after adjusting the
  // scroll offset _only_ the rect would fill the composited area we want to
  // zoom out (we don't want to _just_ scroll, we want to do some amount of
  // zooming, either in or out it doesn't matter which). So translate both rects
  // to the same origin and then compute their overlap, which is what the
  // following calculation does.

  float overlapArea = std::min(aRect.width, aCompositedArea.width) *
                      std::min(aRect.height, aCompositedArea.height);
  float availHeight = std::min(
      aRect.Width() * aCompositedArea.Height() / aCompositedArea.Width(),
      aRect.Height());
  float showing = overlapArea / (aRect.Width() * availHeight);
  float ratioW = aRect.Width() / aCompositedArea.Width();
  float ratioH = aRect.Height() / aCompositedArea.Height();

  return showing > 0.9 && (ratioW > 0.9 || ratioH > 0.9);
}

}  // namespace

static CSSRect AddHMargin(const CSSRect& aRect, const CSSCoord& aMargin,
                          const CSSRect& aRootScrollableRect) {
  CSSRect rect =
      CSSRect(std::max(aRootScrollableRect.X(), aRect.X() - aMargin), aRect.Y(),
              aRect.Width() + 2 * aMargin, aRect.Height());
  // Constrict the rect to the screen's right edge
  rect.SetWidth(std::min(rect.Width(), aRootScrollableRect.XMost() - rect.X()));
  return rect;
}

static CSSRect AddVMargin(const CSSRect& aRect, const CSSCoord& aMargin,
                          const CSSRect& aRootScrollableRect) {
  CSSRect rect =
      CSSRect(aRect.X(), std::max(aRootScrollableRect.Y(), aRect.Y() - aMargin),
              aRect.Width(), aRect.Height() + 2 * aMargin);
  // Constrict the rect to the screen's bottom edge
  rect.SetHeight(
      std::min(rect.Height(), aRootScrollableRect.YMost() - rect.Y()));
  return rect;
}

static bool IsReplacedElement(const nsCOMPtr<dom::Element>& aElement) {
  if (nsIFrame* frame = aElement->GetPrimaryFrame()) {
    if (frame->IsReplaced()) {
      return true;
    }
  }
  return false;
}

static bool HasNonPassiveWheelListenerOnAncestor(nsIContent* aContent) {
  for (nsIContent* content = aContent; content;
       content = content->GetFlattenedTreeParent()) {
    EventListenerManager* elm = content->GetExistingListenerManager();
    if (elm && elm->HasNonPassiveWheelListener()) {
      return true;
    }
  }
  return false;
}

ZoomTarget CalculateRectToZoomTo(
    const RefPtr<dom::Document>& aInProcessRootContentDocument,
    const CSSPoint& aPoint, const DoubleTapToZoomMetrics& aMetrics) {
  // Ensure the layout information we get is up-to-date.
  aInProcessRootContentDocument->FlushPendingNotifications(FlushType::Layout);

  // An empty rect as return value is interpreted as "zoom out".
  const CSSRect zoomOut;

  RefPtr<PresShell> presShell = aInProcessRootContentDocument->GetPresShell();
  if (!presShell) {
    return ZoomTarget{zoomOut, CantZoomOutBehavior::ZoomIn};
  }

  nsIScrollableFrame* rootScrollFrame =
      presShell->GetRootScrollFrameAsScrollable();
  if (!rootScrollFrame) {
    return ZoomTarget{zoomOut, CantZoomOutBehavior::ZoomIn};
  }

  CSSPoint documentRelativePoint =
      aInProcessRootContentDocument->IsTopLevelContentDocument()
          ? CSSPoint::FromAppUnits(ViewportUtils::VisualToLayout(
                CSSPoint::ToAppUnits(aPoint), presShell)) +
                CSSPoint::FromAppUnits(rootScrollFrame->GetScrollPosition())
          : aMetrics.mTransformMatrix.TransformPoint(aPoint);

  nsCOMPtr<dom::Element> element = ElementFromPoint(presShell, aPoint);
  if (!element) {
    return ZoomTarget{zoomOut, CantZoomOutBehavior::ZoomIn, Nothing(),
                      Some(documentRelativePoint)};
  }

  CantZoomOutBehavior cantZoomOutBehavior =
      HasNonPassiveWheelListenerOnAncestor(element)
          ? CantZoomOutBehavior::Nothing
          : CantZoomOutBehavior::ZoomIn;

  while (element && !ShouldZoomToElement(element, aInProcessRootContentDocument,
                                         rootScrollFrame, aMetrics)) {
    element = element->GetFlattenedTreeParentElement();
  }

  if (!element) {
    return ZoomTarget{zoomOut, cantZoomOutBehavior, Nothing(),
                      Some(documentRelativePoint)};
  }

  Maybe<CSSRect> nearestScrollClip;
  CSSRect rect =
      GetBoundingContentRect(element, aInProcessRootContentDocument,
                             rootScrollFrame, aMetrics, &nearestScrollClip);

  // In some cases, like overflow: visible and overflowing content, the bounding
  // client rect of the targeted element won't contain the point the user double
  // tapped on. In that case we use the scrollable overflow rect if it contains
  // the user point.
  if (!rect.Contains(documentRelativePoint)) {
    if (nsIFrame* scrolledFrame = rootScrollFrame->GetScrolledFrame()) {
      if (nsIFrame* f = element->GetPrimaryFrame()) {
        nsRect overflowRect = f->ScrollableOverflowRect();
        nsLayoutUtils::TransformResult res =
            nsLayoutUtils::TransformRect(f, scrolledFrame, overflowRect);
        MOZ_ASSERT(res == nsLayoutUtils::TRANSFORM_SUCCEEDED ||
                   res == nsLayoutUtils::NONINVERTIBLE_TRANSFORM);
        if (res == nsLayoutUtils::TRANSFORM_SUCCEEDED) {
          CSSRect overflowRectCSS = CSSRect::FromAppUnits(overflowRect);

          // In the case of OOP iframes, above |overflowRectCSS| in the iframe
          // documents coords, we need to convert it into the top level coords.
          if (!aInProcessRootContentDocument->IsTopLevelContentDocument()) {
            overflowRectCSS.MoveBy(
                CSSPoint::FromAppUnits(-rootScrollFrame->GetScrollPosition()));
            overflowRectCSS =
                aMetrics.mTransformMatrix.TransformBounds(overflowRectCSS);
          }
          if (nearestScrollClip.isSome()) {
            overflowRectCSS = nearestScrollClip->Intersect(overflowRectCSS);
          }
          if (overflowRectCSS.Contains(documentRelativePoint)) {
            rect = overflowRectCSS;
          }
        }
      }
    }
  }

  CSSRect elementBoundingRect = rect;

  // Generally we zoom to the width of some element, but sometimes we zoom to
  // the height. We set this to true when that happens so that we can add a
  // vertical margin to the rect, otherwise it looks weird.
  bool heightConstrained = false;

  // If the element is taller than the visible area of the page scale
  // the height of the |rect| so that it has the same aspect ratio as
  // the root frame.  The clipped |rect| is centered on the y value of
  // the touch point. This allows tall narrow elements to be zoomed.
  if (!rect.IsEmpty() && aMetrics.mVisualViewport.Width() > 0.0f &&
      aMetrics.mVisualViewport.Height() > 0.0f) {
    // Calculate the height of the rect if it had the same aspect ratio as
    // aMetrics.mVisualViewport.
    const float widthRatio = rect.Width() / aMetrics.mVisualViewport.Width();
    float targetHeight = aMetrics.mVisualViewport.Height() * widthRatio;

    // We don't want to cut off the top or bottoms of replaced elements that are
    // square or wider in aspect ratio.

    // If it's a replaced element and we would otherwise trim it's height below
    if (IsReplacedElement(element) && targetHeight < rect.Height() &&
        // If the target rect is at most 1.1x away from being square or wider
        // aspect ratio
        rect.Height() < 1.1 * rect.Width() &&
        // and our aMetrics.mVisualViewport is wider than it is tall
        aMetrics.mVisualViewport.Width() >= aMetrics.mVisualViewport.Height()) {
      heightConstrained = true;
      // Expand the width of the rect so that it fills aMetrics.mVisualViewport
      // so that if we are already zoomed to this element then the
      // IsRectZoomedIn call below returns true so that we zoom out. This won't
      // change what we actually zoom to as we are just making the rect the same
      // aspect ratio as aMetrics.mVisualViewport.
      float targetWidth = rect.Height() * aMetrics.mVisualViewport.Width() /
                          aMetrics.mVisualViewport.Height();
      MOZ_ASSERT(targetWidth > rect.Width());
      if (targetWidth > rect.Width()) {
        rect.x -= (targetWidth - rect.Width()) / 2;
        rect.SetWidth(targetWidth);
        // keep elementBoundingRect containing rect
        elementBoundingRect = rect;
      }

    } else if (targetHeight < rect.Height()) {
      // Trim the height so that the target rect has the same aspect ratio as
      // aMetrics.mVisualViewport, centering it around the user tap point.
      float newY = documentRelativePoint.y - (targetHeight * 0.5f);
      if ((newY + targetHeight) > rect.YMost()) {
        rect.MoveByY(rect.Height() - targetHeight);
      } else if (newY > rect.Y()) {
        rect.MoveToY(newY);
      }
      rect.SetHeight(targetHeight);
    }
  }

  const CSSCoord margin = 15;
  rect = AddHMargin(rect, margin, aMetrics.mRootScrollableRect);

  if (heightConstrained) {
    rect = AddVMargin(rect, margin, aMetrics.mRootScrollableRect);
  }

  // If the rect is already taking up most of the visible area and is
  // stretching the width of the page, then we want to zoom out instead.
  if (RectHasAlmostSameZoomLevel(rect, aMetrics.mVisualViewport)) {
    return ZoomTarget{zoomOut, cantZoomOutBehavior, Nothing(),
                      Some(documentRelativePoint)};
  }

  elementBoundingRect =
      AddHMargin(elementBoundingRect, margin, aMetrics.mRootScrollableRect);

  // Unlike rect, elementBoundingRect is the full height of the element we are
  // zooming to. If we zoom to it without a margin it can look a weird, so give
  // it a vertical margin.
  elementBoundingRect =
      AddVMargin(elementBoundingRect, margin, aMetrics.mRootScrollableRect);

  rect.Round();
  elementBoundingRect.Round();

  return ZoomTarget{rect, cantZoomOutBehavior, Some(elementBoundingRect),
                    Some(documentRelativePoint)};
}

std::ostream& operator<<(std::ostream& aStream,
                         const DoubleTapToZoomMetrics& aMetrics) {
  aStream << "{ vv=" << aMetrics.mVisualViewport
          << ", rscr=" << aMetrics.mRootScrollableRect
          << ", transform=" << aMetrics.mTransformMatrix << " }";
  return aStream;
}

}  // namespace layers
}  // namespace mozilla