1/*
2 * Copyright (C) 2009 Google Inc. All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions are
6 * met:
7 *
8 *     * Redistributions of source code must retain the above copyright
9 * notice, this list of conditions and the following disclaimer.
10 *     * Redistributions in binary form must reproduce the above
11 * copyright notice, this list of conditions and the following disclaimer
12 * in the documentation and/or other materials provided with the
13 * distribution.
14 *     * Neither the name of Google Inc. nor the names of its
15 * contributors may be used to endorse or promote products derived from
16 * this software without specific prior written permission.
17 *
18 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 */
30
31/**
32 * @constructor
33 * @extends {WebInspector.View}
34 * @param {!WebInspector.PopoverHelper=} popoverHelper
35 */
36WebInspector.Popover = function(popoverHelper)
37{
38    WebInspector.View.call(this);
39    this.markAsRoot();
40    this.element.className = "popover custom-popup-vertical-scroll custom-popup-horizontal-scroll"; // Override
41    this._containerElement = document.createElementWithClass("div", "fill popover-container");
42
43    this._popupArrowElement = this.element.createChild("div", "arrow");
44    this._contentDiv = this.element.createChild("div", "content");
45
46    this._popoverHelper = popoverHelper;
47    this._hideBound = this.hide.bind(this);
48}
49
50WebInspector.Popover.prototype = {
51    /**
52     * @param {!Element} element
53     * @param {!Element|!AnchorBox} anchor
54     * @param {?number=} preferredWidth
55     * @param {?number=} preferredHeight
56     * @param {?WebInspector.Popover.Orientation=} arrowDirection
57     */
58    show: function(element, anchor, preferredWidth, preferredHeight, arrowDirection)
59    {
60        this._innerShow(null, element, anchor, preferredWidth, preferredHeight, arrowDirection);
61    },
62
63    /**
64     * @param {!WebInspector.View} view
65     * @param {!Element|!AnchorBox} anchor
66     * @param {?number=} preferredWidth
67     * @param {?number=} preferredHeight
68     */
69    showView: function(view, anchor, preferredWidth, preferredHeight)
70    {
71        this._innerShow(view, view.element, anchor, preferredWidth, preferredHeight);
72    },
73
74    /**
75     * @param {?WebInspector.View} view
76     * @param {!Element} contentElement
77     * @param {!Element|!AnchorBox} anchor
78     * @param {?number=} preferredWidth
79     * @param {?number=} preferredHeight
80     * @param {?WebInspector.Popover.Orientation=} arrowDirection
81     */
82    _innerShow: function(view, contentElement, anchor, preferredWidth, preferredHeight, arrowDirection)
83    {
84        if (this._disposed)
85            return;
86        this.contentElement = contentElement;
87
88        // This should not happen, but we hide previous popup to be on the safe side.
89        if (WebInspector.Popover._popover)
90            WebInspector.Popover._popover.hide();
91        WebInspector.Popover._popover = this;
92
93        // Temporarily attach in order to measure preferred dimensions.
94        var preferredSize = view ? view.measurePreferredSize() : this.contentElement.measurePreferredSize();
95        preferredWidth = preferredWidth || preferredSize.width;
96        preferredHeight = preferredHeight || preferredSize.height;
97
98        window.addEventListener("resize", this._hideBound, false);
99        document.body.appendChild(this._containerElement);
100        WebInspector.View.prototype.show.call(this, this._containerElement);
101
102        if (view)
103            view.show(this._contentDiv);
104        else
105            this._contentDiv.appendChild(this.contentElement);
106
107        this._positionElement(anchor, preferredWidth, preferredHeight, arrowDirection);
108
109        if (this._popoverHelper) {
110            this._contentDiv.addEventListener("mousemove", this._popoverHelper._killHidePopoverTimer.bind(this._popoverHelper), true);
111            this.element.addEventListener("mouseout", this._popoverHelper._popoverMouseOut.bind(this._popoverHelper), true);
112        }
113    },
114
115    hide: function()
116    {
117        window.removeEventListener("resize", this._hideBound, false);
118        this.detach();
119        this._containerElement.remove();
120        delete WebInspector.Popover._popover;
121    },
122
123    get disposed()
124    {
125        return this._disposed;
126    },
127
128    dispose: function()
129    {
130        if (this.isShowing())
131            this.hide();
132        this._disposed = true;
133    },
134
135    setCanShrink: function(canShrink)
136    {
137        this._hasFixedHeight = !canShrink;
138        this._contentDiv.classList.add("fixed-height");
139    },
140
141    /**
142     * @param {!Element|!AnchorBox} anchorElement
143     * @param {number} preferredWidth
144     * @param {number} preferredHeight
145     * @param {?WebInspector.Popover.Orientation=} arrowDirection
146     */
147    _positionElement: function(anchorElement, preferredWidth, preferredHeight, arrowDirection)
148    {
149        const borderWidth = 25;
150        const scrollerWidth = this._hasFixedHeight ? 0 : 11;
151        const arrowHeight = 15;
152        const arrowOffset = 10;
153        const borderRadius = 10;
154
155        // Skinny tooltips are not pretty, their arrow location is not nice.
156        preferredWidth = Math.max(preferredWidth, 50);
157        // Position relative to main DevTools element.
158        const container = WebInspector.Dialog.modalHostView().element;
159        const totalWidth = container.offsetWidth;
160        const totalHeight = container.offsetHeight;
161
162        var anchorBox = anchorElement instanceof AnchorBox ? anchorElement : anchorElement.boxInWindow(window);
163        anchorBox = anchorBox.relativeToElement(container);
164        var newElementPosition = { x: 0, y: 0, width: preferredWidth + scrollerWidth, height: preferredHeight };
165
166        var verticalAlignment;
167        var roomAbove = anchorBox.y;
168        var roomBelow = totalHeight - anchorBox.y - anchorBox.height;
169
170        if ((roomAbove > roomBelow) || (arrowDirection === WebInspector.Popover.Orientation.Bottom)) {
171            // Positioning above the anchor.
172            if ((anchorBox.y > newElementPosition.height + arrowHeight + borderRadius) || (arrowDirection === WebInspector.Popover.Orientation.Bottom))
173                newElementPosition.y = anchorBox.y - newElementPosition.height - arrowHeight;
174            else {
175                newElementPosition.y = borderRadius;
176                newElementPosition.height = anchorBox.y - borderRadius * 2 - arrowHeight;
177                if (this._hasFixedHeight && newElementPosition.height < preferredHeight) {
178                    newElementPosition.y = borderRadius;
179                    newElementPosition.height = preferredHeight;
180                }
181            }
182            verticalAlignment = WebInspector.Popover.Orientation.Bottom;
183        } else {
184            // Positioning below the anchor.
185            newElementPosition.y = anchorBox.y + anchorBox.height + arrowHeight;
186            if ((newElementPosition.y + newElementPosition.height + borderRadius >= totalHeight) && (arrowDirection !== WebInspector.Popover.Orientation.Top)) {
187                newElementPosition.height = totalHeight - borderRadius - newElementPosition.y;
188                if (this._hasFixedHeight && newElementPosition.height < preferredHeight) {
189                    newElementPosition.y = totalHeight - preferredHeight - borderRadius;
190                    newElementPosition.height = preferredHeight;
191                }
192            }
193            // Align arrow.
194            verticalAlignment = WebInspector.Popover.Orientation.Top;
195        }
196
197        var horizontalAlignment;
198        if (anchorBox.x + newElementPosition.width < totalWidth) {
199            newElementPosition.x = Math.max(borderRadius, anchorBox.x - borderRadius - arrowOffset);
200            horizontalAlignment = "left";
201        } else if (newElementPosition.width + borderRadius * 2 < totalWidth) {
202            newElementPosition.x = totalWidth - newElementPosition.width - borderRadius;
203            horizontalAlignment = "right";
204            // Position arrow accurately.
205            var arrowRightPosition = Math.max(0, totalWidth - anchorBox.x - anchorBox.width - borderRadius - arrowOffset);
206            arrowRightPosition += anchorBox.width / 2;
207            arrowRightPosition = Math.min(arrowRightPosition, newElementPosition.width - borderRadius - arrowOffset);
208            this._popupArrowElement.style.right = arrowRightPosition + "px";
209        } else {
210            newElementPosition.x = borderRadius;
211            newElementPosition.width = totalWidth - borderRadius * 2;
212            newElementPosition.height += scrollerWidth;
213            horizontalAlignment = "left";
214            if (verticalAlignment === WebInspector.Popover.Orientation.Bottom)
215                newElementPosition.y -= scrollerWidth;
216            // Position arrow accurately.
217            this._popupArrowElement.style.left = Math.max(0, anchorBox.x - borderRadius * 2 - arrowOffset) + "px";
218            this._popupArrowElement.style.left += anchorBox.width / 2;
219        }
220
221        this.element.className = "popover custom-popup-vertical-scroll custom-popup-horizontal-scroll " + verticalAlignment + "-" + horizontalAlignment + "-arrow";
222        this.element.positionAt(newElementPosition.x - borderWidth, newElementPosition.y - borderWidth, container);
223        this.element.style.width = newElementPosition.width + borderWidth * 2 + "px";
224        this.element.style.height = newElementPosition.height + borderWidth * 2 + "px";
225    },
226
227    __proto__: WebInspector.View.prototype
228}
229
230/**
231 * @constructor
232 * @param {!Element} panelElement
233 * @param {function(!Element, !Event):(!Element|!AnchorBox|undefined)} getAnchor
234 * @param {function(!Element, !WebInspector.Popover):undefined} showPopover
235 * @param {function()=} onHide
236 * @param {boolean=} disableOnClick
237 */
238WebInspector.PopoverHelper = function(panelElement, getAnchor, showPopover, onHide, disableOnClick)
239{
240    this._panelElement = panelElement;
241    this._getAnchor = getAnchor;
242    this._showPopover = showPopover;
243    this._onHide = onHide;
244    this._disableOnClick = !!disableOnClick;
245    panelElement.addEventListener("mousedown", this._mouseDown.bind(this), false);
246    panelElement.addEventListener("mousemove", this._mouseMove.bind(this), false);
247    panelElement.addEventListener("mouseout", this._mouseOut.bind(this), false);
248    this.setTimeout(1000, 500);
249}
250
251WebInspector.PopoverHelper.prototype = {
252    /**
253     * @param {number} timeout
254     * @param {number=} hideTimeout
255     */
256    setTimeout: function(timeout, hideTimeout)
257    {
258        this._timeout = timeout;
259        if (typeof hideTimeout === "number")
260            this._hideTimeout = hideTimeout;
261        else
262            this._hideTimeout = timeout / 2;
263    },
264
265    /**
266     * @param {!MouseEvent} event
267     * @return {boolean}
268     */
269    _eventInHoverElement: function(event)
270    {
271        if (!this._hoverElement)
272            return false;
273        var box = this._hoverElement instanceof AnchorBox ? this._hoverElement : this._hoverElement.boxInWindow();
274        return (box.x <= event.clientX && event.clientX <= box.x + box.width &&
275            box.y <= event.clientY && event.clientY <= box.y + box.height);
276    },
277
278    _mouseDown: function(event)
279    {
280        if (this._disableOnClick || !this._eventInHoverElement(event))
281            this.hidePopover();
282        else {
283            this._killHidePopoverTimer();
284            this._handleMouseAction(event, true);
285        }
286    },
287
288    _mouseMove: function(event)
289    {
290        // Pretend that nothing has happened.
291        if (this._eventInHoverElement(event))
292            return;
293
294        this._startHidePopoverTimer();
295        this._handleMouseAction(event, false);
296    },
297
298    _popoverMouseOut: function(event)
299    {
300        if (!this.isPopoverVisible())
301            return;
302        if (event.relatedTarget && !event.relatedTarget.isSelfOrDescendant(this._popover._contentDiv))
303            this._startHidePopoverTimer();
304    },
305
306    _mouseOut: function(event)
307    {
308        if (!this.isPopoverVisible())
309            return;
310        if (!this._eventInHoverElement(event))
311            this._startHidePopoverTimer();
312    },
313
314    _startHidePopoverTimer: function()
315    {
316        // User has 500ms (this._hideTimeout) to reach the popup.
317        if (!this._popover || this._hidePopoverTimer)
318            return;
319
320        /**
321         * @this {WebInspector.PopoverHelper}
322         */
323        function doHide()
324        {
325            this._hidePopover();
326            delete this._hidePopoverTimer;
327        }
328        this._hidePopoverTimer = setTimeout(doHide.bind(this), this._hideTimeout);
329    },
330
331    _handleMouseAction: function(event, isMouseDown)
332    {
333        this._resetHoverTimer();
334        if (event.which && this._disableOnClick)
335            return;
336        this._hoverElement = this._getAnchor(event.target, event);
337        if (!this._hoverElement)
338            return;
339        const toolTipDelay = isMouseDown ? 0 : (this._popup ? this._timeout * 0.6 : this._timeout);
340        this._hoverTimer = setTimeout(this._mouseHover.bind(this, this._hoverElement), toolTipDelay);
341    },
342
343    _resetHoverTimer: function()
344    {
345        if (this._hoverTimer) {
346            clearTimeout(this._hoverTimer);
347            delete this._hoverTimer;
348        }
349    },
350
351    /**
352     * @return {boolean}
353     */
354    isPopoverVisible: function()
355    {
356        return !!this._popover;
357    },
358
359    hidePopover: function()
360    {
361        this._resetHoverTimer();
362        this._hidePopover();
363    },
364
365    _hidePopover: function()
366    {
367        if (!this._popover)
368            return;
369
370        if (this._onHide)
371            this._onHide();
372
373        this._popover.dispose();
374        delete this._popover;
375        this._hoverElement = null;
376    },
377
378    _mouseHover: function(element)
379    {
380        delete this._hoverTimer;
381
382        this._hidePopover();
383        this._popover = new WebInspector.Popover(this);
384        this._showPopover(element, this._popover);
385    },
386
387    _killHidePopoverTimer: function()
388    {
389        if (this._hidePopoverTimer) {
390            clearTimeout(this._hidePopoverTimer);
391            delete this._hidePopoverTimer;
392
393            // We know that we reached the popup, but we might have moved over other elements.
394            // Discard pending command.
395            this._resetHoverTimer();
396        }
397    }
398}
399
400/** @enum {string} */
401WebInspector.Popover.Orientation = {
402    Top: "top",
403    Bottom: "bottom"
404}
405