1/*
2 * Copyright (C) 2013 Apple 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
6 * are met:
7 * 1. Redistributions of source code must retain the above copyright
8 *    notice, this list of conditions and the following disclaimer.
9 * 2. Redistributions in binary form must reproduce the above copyright
10 *    notice, this list of conditions and the following disclaimer in the
11 *    documentation and/or other materials provided with the distribution.
12 *
13 * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17 * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23 * THE POSSIBILITY OF SUCH DAMAGE.
24 */
25
26WebInspector.Popover = function(delegate) {
27    WebInspector.Object.call(this);
28
29    this.delegate = delegate;
30    this._edge = null;
31    this._frame = new WebInspector.Rect;
32    this._content = null;
33    this._targetFrame = new WebInspector.Rect;
34    this._anchorPoint = new WebInspector.Point;
35    this._preferredEdges = null;
36
37    this._contentNeedsUpdate = false;
38
39    this._element = document.createElement("div");
40    this._element.className = WebInspector.Popover.StyleClassName;
41    this._canvasId = "popover-" + (WebInspector.Popover.canvasId++);
42    this._element.style.backgroundImage = "-webkit-canvas(" + this._canvasId + ")";
43    this._element.addEventListener("transitionend", this, true);
44
45    this._container = this._element.appendChild(document.createElement("div"));
46    this._container.className = "container";
47};
48
49WebInspector.Popover.StyleClassName = "popover";
50WebInspector.Popover.FadeOutClassName = "fade-out";
51WebInspector.Popover.canvasId = 0;
52WebInspector.Popover.CornerRadius = 5;
53WebInspector.Popover.MinWidth = 40;
54WebInspector.Popover.MinHeight = 40;
55WebInspector.Popover.ShadowPadding = 5;
56WebInspector.Popover.ContentPadding = 5;
57WebInspector.Popover.AnchorSize = new WebInspector.Size(22, 11);
58WebInspector.Popover.ShadowEdgeInsets = new WebInspector.EdgeInsets(WebInspector.Popover.ShadowPadding);
59
60WebInspector.Popover.prototype = {
61    constructor: WebInspector.Popover,
62
63    // Public
64
65    get element()
66    {
67        return this._element;
68    },
69
70    get frame()
71    {
72        return this._frame;
73    },
74
75    get visible()
76    {
77        return this._element.parentNode === document.body && !this._element.classList.contains(WebInspector.Popover.FadeOutClassName);
78    },
79
80    set frame(frame)
81    {
82        this._element.style.left = frame.minX() + "px";
83        this._element.style.top = frame.minY() + "px";
84        this._element.style.width = frame.size.width + "px";
85        this._element.style.height = frame.size.height + "px";
86        this._element.style.backgroundSize = frame.size.width + "px " + frame.size.height + "px";
87        this._frame = frame;
88    },
89
90    set content(content)
91    {
92        if (content === this._content)
93            return;
94
95        this._content = content;
96
97        this._contentNeedsUpdate = true;
98
99        if (this.visible)
100            this._update(true);
101    },
102
103    update: function()
104    {
105        if (!this.visible)
106            return;
107
108        var previouslyFocusedElement = document.activeElement;
109
110        this._contentNeedsUpdate = true;
111        this._update(true);
112
113        if (previouslyFocusedElement)
114            previouslyFocusedElement.focus();
115    },
116
117    present: function(targetFrame, preferredEdges)
118    {
119        this._targetFrame = targetFrame;
120        this._preferredEdges = preferredEdges;
121
122        if (!this._content)
123            return;
124
125        window.addEventListener("mousedown", this, true);
126        window.addEventListener("scroll", this, true);
127
128        this._update();
129    },
130
131    dismiss: function()
132    {
133        if (this._element.parentNode !== document.body)
134            return;
135
136        window.removeEventListener("mousedown", this, true);
137        window.removeEventListener("scroll", this, true);
138
139        this._element.classList.add(WebInspector.Popover.FadeOutClassName);
140
141        if (this.delegate && typeof this.delegate.willDismissPopover === "function")
142            this.delegate.willDismissPopover(this);
143    },
144
145    handleEvent: function(event)
146    {
147        switch (event.type) {
148        case "mousedown":
149        case "scroll":
150            if (!this._element.contains(event.target))
151                this.dismiss();
152            break;
153        case "transitionend":
154            if (event.target === this._element) {
155                document.body.removeChild(this._element);
156                this._element.classList.remove(WebInspector.Popover.FadeOutClassName);
157                this._container.textContent = "";
158                if (this.delegate && typeof this.delegate.didDismissPopover === "function")
159                    this.delegate.didDismissPopover(this);
160                break;
161            }
162        }
163    },
164
165    // Private
166
167    _update: function(shouldAnimate)
168    {
169        if (shouldAnimate)
170            var previousEdge = this._edge;
171
172        var targetFrame = this._targetFrame;
173        var preferredEdges = this._preferredEdges;
174
175        // Ensure our element is on display so that its metrics can be resolved
176        // or interrupt any pending transition to remove it from display.
177        if (this._element.parentNode !== document.body)
178            document.body.appendChild(this._element);
179        else
180            this._element.classList.remove(WebInspector.Popover.FadeOutClassName);
181
182        if (this._contentNeedsUpdate) {
183            // Reset CSS properties on element so that the element may be sized to fit its content.
184            this._element.style.removeProperty("left");
185            this._element.style.removeProperty("top");
186            this._element.style.removeProperty("width");
187            this._element.style.removeProperty("height");
188            if (this._edge !== null)
189                this._element.classList.remove(this._cssClassNameForEdge());
190
191            // Add the content in place of the wrapper to get the raw metrics.
192            this._element.replaceChild(this._content, this._container);
193
194            // Get the ideal size for the popover to fit its content.
195            var popoverBounds = this._element.getBoundingClientRect();
196            this._preferredSize = new WebInspector.Size(Math.ceil(popoverBounds.width), Math.ceil(popoverBounds.height));
197        }
198
199        const titleBarOffset = WebInspector.Platform.name === "mac" && !WebInspector.Platform.isLegacyMacOS ? 22 : 0;
200        var containerFrame = new WebInspector.Rect(0, titleBarOffset, window.innerWidth, window.innerHeight - titleBarOffset);
201        // The frame of the window with a little inset to make sure we have room for shadows.
202        containerFrame = containerFrame.inset(WebInspector.Popover.ShadowEdgeInsets);
203
204        // Work out the metrics for all edges.
205        var metrics = new Array(preferredEdges.length);
206        for (var edgeName in WebInspector.RectEdge) {
207            var edge = WebInspector.RectEdge[edgeName];
208            var item = {
209                edge: edge,
210                metrics: this._bestMetricsForEdge(this._preferredSize, targetFrame, containerFrame, edge)
211            };
212            var preferredIndex = preferredEdges.indexOf(edge);
213            if (preferredIndex !== -1)
214                metrics[preferredIndex] = item;
215            else
216                metrics.push(item);
217        }
218
219        function area(size)
220        {
221            return size.width * size.height;
222        }
223
224        // Find if any of those fit better than the frame for the preferred edge.
225        var bestEdge = metrics[0].edge;
226        var bestMetrics = metrics[0].metrics;
227        for (var i = 1; i < metrics.length; i++) {
228            var itemMetrics = metrics[i].metrics;
229            if (area(itemMetrics.contentSize) > area(bestMetrics.contentSize)) {
230                bestEdge = metrics[i].edge;
231                bestMetrics = itemMetrics;
232            }
233        }
234
235        var anchorPoint;
236        var bestFrame = bestMetrics.frame.round();
237
238        this._edge = bestEdge;
239
240        if (bestFrame === WebInspector.Rect.ZERO_RECT) {
241            // The target for the popover is offscreen.
242            this.dismiss();
243        } else {
244            switch (bestEdge) {
245            case WebInspector.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
246                anchorPoint = new WebInspector.Point(bestFrame.size.width - WebInspector.Popover.ShadowPadding, targetFrame.midY() - bestFrame.minY());
247                break;
248            case WebInspector.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
249                anchorPoint = new WebInspector.Point(WebInspector.Popover.ShadowPadding, targetFrame.midY() - bestFrame.minY());
250                break;
251            case WebInspector.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
252                anchorPoint = new WebInspector.Point(targetFrame.midX() - bestFrame.minX(), bestFrame.size.height - WebInspector.Popover.ShadowPadding);
253                break;
254            case WebInspector.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
255                anchorPoint = new WebInspector.Point(targetFrame.midX() - bestFrame.minX(), WebInspector.Popover.ShadowPadding);
256                break;
257            }
258
259            this._element.classList.add(this._cssClassNameForEdge());
260
261            if (shouldAnimate && this._edge === previousEdge)
262                this._animateFrame(bestFrame);
263            else {
264                 this.frame = bestFrame;
265                 this._setAnchorPoint(anchorPoint);
266                 this._drawBackground();
267            }
268
269            // Make sure content is centered in case either of the dimension is smaller than the minimal bounds.
270            if (this._preferredSize.width < WebInspector.Popover.MinWidth || this._preferredSize.height < WebInspector.Popover.MinHeight)
271                this._container.classList.add("center");
272            else
273                this._container.classList.remove("center");
274        }
275
276        // Wrap the content in the container so that it's located correctly.
277        if (this._contentNeedsUpdate) {
278            this._container.textContent = "";
279            this._element.replaceChild(this._container, this._content);
280            this._container.appendChild(this._content);
281        }
282
283        this._contentNeedsUpdate = false;
284    },
285
286    _cssClassNameForEdge: function()
287    {
288        switch (this._edge) {
289        case WebInspector.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
290            return "arrow-right";
291        case WebInspector.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
292            return "arrow-left";
293        case WebInspector.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
294            return "arrow-down";
295        case WebInspector.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
296            return "arrow-up";
297        }
298        console.error("Unknown edge.");
299        return "arrow-up";
300    },
301
302    _setAnchorPoint: function(anchorPoint) {
303        anchorPoint.x = Math.floor(anchorPoint.x);
304        anchorPoint.y = Math.floor(anchorPoint.y);
305        this._anchorPoint = anchorPoint;
306    },
307
308    _animateFrame: function(toFrame)
309    {
310        var startTime = Date.now();
311        var duration = 350;
312        var epsilon = 1 / (200 * duration);
313        var spline = new WebInspector.UnitBezier(0.25, 0.1, 0.25, 1);
314
315        var fromFrame = this._frame.copy();
316
317        var absoluteAnchorPoint = new WebInspector.Point(
318            fromFrame.minX() + this._anchorPoint.x,
319            fromFrame.minY() + this._anchorPoint.y
320        );
321
322        function animatedValue(from, to, progress)
323        {
324            return from + (to - from) * progress;
325        }
326
327        function drawBackground()
328        {
329            var progress = spline.solve(Math.min((Date.now() - startTime) / duration, 1), epsilon);
330
331            this.frame = new WebInspector.Rect(
332                animatedValue(fromFrame.minX(), toFrame.minX(), progress),
333                animatedValue(fromFrame.minY(), toFrame.minY(), progress),
334                animatedValue(fromFrame.size.width, toFrame.size.width, progress),
335                animatedValue(fromFrame.size.height, toFrame.size.height, progress)
336            ).round();
337
338            this._setAnchorPoint(new WebInspector.Point(
339                absoluteAnchorPoint.x - this._frame.minX(),
340                absoluteAnchorPoint.y - this._frame.minY()
341            ));
342
343            this._drawBackground();
344
345            if (progress < 1)
346                window.requestAnimationFrame(drawBackground.bind(this));
347        }
348
349        drawBackground.call(this);
350    },
351
352    _drawBackground: function()
353    {
354        var scaleFactor = window.devicePixelRatio;
355
356        var width = this._frame.size.width;
357        var height = this._frame.size.height;
358        var scaledWidth = width * scaleFactor;
359        var scaledHeight = height * scaleFactor;
360
361        // Create a scratch canvas so we can draw the popover that will later be drawn into
362        // the final context with a shadow.
363        var scratchCanvas = document.createElement("canvas");
364        scratchCanvas.width = scaledWidth;
365        scratchCanvas.height = scaledHeight;
366
367        var ctx = scratchCanvas.getContext("2d");
368        ctx.scale(scaleFactor, scaleFactor);
369
370        // Bounds of the path don't take into account the arrow, but really only the tight bounding box
371        // of the content contained within the frame.
372        var bounds;
373        var arrowHeight = WebInspector.Popover.AnchorSize.height;
374        switch (this._edge) {
375        case WebInspector.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
376            bounds = new WebInspector.Rect(0, 0, width - arrowHeight, height);
377            break;
378        case WebInspector.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
379            bounds = new WebInspector.Rect(arrowHeight, 0, width - arrowHeight, height);
380            break;
381        case WebInspector.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
382            bounds = new WebInspector.Rect(0, 0, width, height - arrowHeight);
383            break;
384        case WebInspector.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
385            bounds = new WebInspector.Rect(0, arrowHeight, width, height - arrowHeight);
386            break;
387        }
388
389        bounds = bounds.inset(WebInspector.Popover.ShadowEdgeInsets);
390
391        // Clip the frame.
392        ctx.fillStyle = "black";
393        this._drawFrame(ctx, bounds, this._edge, this._anchorPoint);
394        ctx.clip();
395
396        // Gradient fill, top-to-bottom.
397        var fillGradient = ctx.createLinearGradient(0, 0, 0, height);
398        fillGradient.addColorStop(0, "rgba(255, 255, 255, 0.95)");
399        fillGradient.addColorStop(1, "rgba(235, 235, 235, 0.95)");
400        ctx.fillStyle = fillGradient;
401        ctx.fillRect(0, 0, width, height);
402
403        // Stroke.
404        ctx.strokeStyle = "rgba(0, 0, 0, 0.25)";
405        ctx.lineWidth = 2;
406        this._drawFrame(ctx, bounds, this._edge, this._anchorPoint);
407        ctx.stroke();
408
409        // Draw the popover into the final context with a drop shadow.
410        var finalContext = document.getCSSCanvasContext("2d", this._canvasId, scaledWidth, scaledHeight);
411
412        finalContext.clearRect(0, 0, scaledWidth, scaledHeight);
413
414        finalContext.shadowOffsetX = 1;
415        finalContext.shadowOffsetY = 1;
416        finalContext.shadowBlur = 5;
417        finalContext.shadowColor = "rgba(0, 0, 0, 0.5)";
418
419        finalContext.drawImage(scratchCanvas, 0, 0, scaledWidth, scaledHeight);
420    },
421
422    _bestMetricsForEdge: function(preferredSize, targetFrame, containerFrame, edge)
423    {
424        var x, y;
425        var width = preferredSize.width + (WebInspector.Popover.ShadowPadding * 2) + (WebInspector.Popover.ContentPadding * 2);
426        var height = preferredSize.height + (WebInspector.Popover.ShadowPadding * 2) + (WebInspector.Popover.ContentPadding * 2);
427        var arrowLength = WebInspector.Popover.AnchorSize.height;
428
429        switch (edge) {
430        case WebInspector.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
431            width += arrowLength;
432            x = targetFrame.origin.x - width + WebInspector.Popover.ShadowPadding;
433            y = targetFrame.origin.y - (height - targetFrame.size.height) / 2;
434            break;
435        case WebInspector.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
436            width += arrowLength;
437            x = targetFrame.origin.x + targetFrame.size.width - WebInspector.Popover.ShadowPadding;
438            y = targetFrame.origin.y - (height - targetFrame.size.height) / 2;
439            break;
440        case WebInspector.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
441            height += arrowLength;
442            x = targetFrame.origin.x - (width - targetFrame.size.width) / 2;
443            y = targetFrame.origin.y - height + WebInspector.Popover.ShadowPadding;
444            break;
445        case WebInspector.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
446            height += arrowLength;
447            x = targetFrame.origin.x - (width - targetFrame.size.width) / 2;
448            y = targetFrame.origin.y + targetFrame.size.height - WebInspector.Popover.ShadowPadding;
449            break;
450        }
451
452        if (edge === WebInspector.RectEdge.MIN_X || edge === WebInspector.RectEdge.MAX_X) {
453            if (y < containerFrame.minY())
454                y = containerFrame.minY();
455            if (y + height > containerFrame.maxY())
456                y = containerFrame.maxY() - height;
457        } else {
458            if (x < containerFrame.minX())
459                x = containerFrame.minX();
460            if (x + width > containerFrame.maxX())
461                x = containerFrame.maxX() - width;
462        }
463
464        var preferredFrame = new WebInspector.Rect(x, y, width, height);
465        var bestFrame = preferredFrame.intersectionWithRect(containerFrame);
466
467        width = bestFrame.size.width - (WebInspector.Popover.ShadowPadding * 2);
468        height = bestFrame.size.height - (WebInspector.Popover.ShadowPadding * 2);
469
470        switch (edge) {
471        case WebInspector.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
472        case WebInspector.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
473            width -= arrowLength;
474            break;
475        case WebInspector.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
476        case WebInspector.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
477            height -= arrowLength;
478            break;
479        }
480
481        return {
482            frame: bestFrame,
483            contentSize: new WebInspector.Size(width, height)
484        };
485    },
486
487    _drawFrame: function(ctx, bounds, anchorEdge)
488    {
489        var r = WebInspector.Popover.CornerRadius;
490        var arrowHalfLength = WebInspector.Popover.AnchorSize.width / 2;
491        var anchorPoint = this._anchorPoint;
492
493        ctx.beginPath();
494        switch (anchorEdge) {
495        case WebInspector.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
496            ctx.moveTo(bounds.maxX(), bounds.minY() + r);
497            ctx.lineTo(bounds.maxX(), anchorPoint.y - arrowHalfLength);
498            ctx.lineTo(anchorPoint.x, anchorPoint.y);
499            ctx.lineTo(bounds.maxX(), anchorPoint.y + arrowHalfLength);
500            ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), r);
501            ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), r);
502            ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), r);
503            ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), r);
504            break;
505        case WebInspector.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
506            ctx.moveTo(bounds.minX(), bounds.maxY() - r);
507            ctx.lineTo(bounds.minX(), anchorPoint.y + arrowHalfLength);
508            ctx.lineTo(anchorPoint.x, anchorPoint.y);
509            ctx.lineTo(bounds.minX(), anchorPoint.y - arrowHalfLength);
510            ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), r);
511            ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), r);
512            ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), r);
513            ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), r);
514            break;
515        case WebInspector.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
516            ctx.moveTo(bounds.maxX() - r, bounds.maxY());
517            ctx.lineTo(anchorPoint.x + arrowHalfLength, bounds.maxY());
518            ctx.lineTo(anchorPoint.x, anchorPoint.y);
519            ctx.lineTo(anchorPoint.x - arrowHalfLength, bounds.maxY());
520            ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), r);
521            ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), r);
522            ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), r);
523            ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), r);
524            break;
525        case WebInspector.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
526            ctx.moveTo(bounds.minX() + r, bounds.minY());
527            ctx.lineTo(anchorPoint.x - arrowHalfLength, bounds.minY());
528            ctx.lineTo(anchorPoint.x, anchorPoint.y);
529            ctx.lineTo(anchorPoint.x + arrowHalfLength, bounds.minY());
530            ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), r);
531            ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), r);
532            ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), r);
533            ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), r);
534            break;
535        }
536        ctx.closePath();
537    }
538
539};
540
541WebInspector.Popover.prototype.__proto__ = WebInspector.Object.prototype;
542