blob: e408b879b3549c84fa3c23a4c97c593c666a9e7c [file] [log] [blame]
/*
* Copyright (C) 2013, 2014 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/
Dashboard.Popover = function(delegate)
{
BaseObject.call(this);
this.delegate = delegate;
this._edge = null;
this._frame = new Dashboard.Rect;
this._content = null;
this._targetFrame = new Dashboard.Rect;
this._preferredEdges = null;
this._contentNeedsUpdate = false;
this._element = document.createElement("div");
this._element.className = Dashboard.Popover.StyleClassName;
this._canvasId = "popover-" + (Dashboard.Popover.canvasId++);
this._element.style.backgroundImage = "-webkit-canvas(" + this._canvasId + ")";
this._element.addEventListener("transitionend", this, true);
this._container = this._element.appendChild(document.createElement("div"));
this._container.className = "container";
};
Dashboard.Popover.StyleClassName = "popover";
Dashboard.Popover.VisibleClassName = "visible";
Dashboard.Popover.StepInClassName = "step-in";
Dashboard.Popover.FadeOutClassName = "fade-out";
Dashboard.Popover.PreventDocumentScrollingClassName = "popover-prevent-document-scrolling";
Dashboard.Popover.canvasId = 0;
Dashboard.Popover.CornerRadius = 5;
Dashboard.Popover.MinWidth = 40;
Dashboard.Popover.MinHeight = 40;
Dashboard.Popover.ShadowPadding = 5;
Dashboard.Popover.ContentPadding = 5;
Dashboard.Popover.AnchorSize = new Dashboard.Size(22, 11);
Dashboard.Popover.ShadowEdgeInsets = new Dashboard.EdgeInsets(Dashboard.Popover.ShadowPadding);
BaseObject.addConstructorFunctions(Dashboard.Popover);
Dashboard.Popover.prototype = {
constructor: Dashboard.Popover,
__proto__: BaseObject.prototype,
// Public
get element()
{
return this._element;
},
get frame()
{
return this._frame;
},
set frame(frame)
{
this._element.style.left = window.scrollX + frame.origin.x + "px";
this._element.style.top = window.scrollY + frame.origin.y + "px";
this._element.style.width = frame.size.width + "px";
this._element.style.height = frame.size.height + "px";
this._element.style.backgroundSize = frame.size.width + "px " + frame.size.height + "px";
this._frame = frame;
},
get visible()
{
return this._element.parentNode === document.body
&& !this._element.classList.contains(Dashboard.Popover.StepInClassName);
},
get potentiallyVisible()
{
return this._element.parentNode === document.body;
},
set content(content)
{
if (content === this._content)
return;
console.assert(content);
this._content = content;
this._contentNeedsUpdate = true;
if (this.potentiallyVisible)
this._update();
},
update: function()
{
if (!this.potentiallyVisible)
return;
var previouslyFocusedElement = document.activeElement;
this._contentNeedsUpdate = true;
this._update();
if (previouslyFocusedElement)
previouslyFocusedElement.focus();
},
/**
* @param {Dashboard.Rect} targetFrame
* @param {Element} content
* @param {Dashboard.RectEdge}[] preferredEdges
*/
present: function(targetFrame, preferredEdges)
{
this._targetFrame = targetFrame;
this._preferredEdges = preferredEdges;
console.assert(this._content);
window.addEventListener("mousedown", this, true);
window.addEventListener("scroll", this, true);
this._update();
this._element.classList.add(Dashboard.Popover.StepInClassName);
this._element.addEventListener("mousewheel", this, true);
this._element.addEventListener("mouseleave", this, true);
},
makeVisibleImmediately: function()
{
console.assert(this._content);
this._finalizePresentation();
},
dismiss: function()
{
if (this._element.parentNode !== document.body)
return;
this._element.classList.add(Dashboard.Popover.FadeOutClassName);
},
dismissImmediately: function()
{
if (this._element.parentNode !== document.body)
return;
this._finalizeDismissal();
},
handleEvent: function(event)
{
switch (event.type) {
case "mousedown":
case "scroll":
if (!this._element.contains(event.target))
this.dismissImmediately();
break;
case "transitionend":
if (this._element.classList.contains(Dashboard.Popover.StepInClassName))
this._finalizePresentation();
else if (this._element.classList.contains(Dashboard.Popover.FadeOutClassName))
this._finalizeDismissal();
break;
case "mousewheel":
// Scrolling inside a popover should not cascade to document when reaching a bound, because that would make it disappear unexpectedly.
// FIXME: We should use mouseenter for better performance once it works reliably, see <https://bugs.webkit.org/show_bug.cgi?id=120786>.
if (this._container.offsetHeight < this._container.scrollHeight)
document.body.classList.add(Dashboard.Popover.PreventDocumentScrollingClassName);
break;
case "mouseleave":
if (!this._element.isSelfOrAncestor(event.toElement))
document.body.classList.remove(Dashboard.Popover.PreventDocumentScrollingClassName);
break;
}
},
// Private
_update: function()
{
var targetFrame = this._targetFrame;
var preferredEdges = this._preferredEdges;
// Ensure our element is on display so that its metrics can be resolved.
if (this._element.parentNode !== document.body)
document.body.appendChild(this._element);
if (this._contentNeedsUpdate) {
// Reset CSS properties on element so that the element may be sized to fit its content.
this._element.style.removeProperty("left");
this._element.style.removeProperty("top");
this._element.style.removeProperty("width");
this._element.style.removeProperty("height");
if (this._edge !== null)
this._element.classList.remove(this._cssClassNameForEdge());
// Add the content in place of the wrapper to get the raw metrics.
this._element.replaceChild(this._content, this._container);
// Get the ideal size for the popover to fit its content.
var popoverBounds = this._element.getBoundingClientRect();
this._preferredSize = new Dashboard.Size(Math.ceil(popoverBounds.width), Math.ceil(popoverBounds.height));
}
// The frame of the window with a little inset to make sure we have room for shadows.
var containerFrame = new Dashboard.Rect(0, 0, window.innerWidth, window.innerHeight);
containerFrame = containerFrame.inset(Dashboard.Popover.ShadowEdgeInsets);
// Work out the metrics for all edges.
var metrics = new Array(preferredEdges.length);
for (var edgeName in Dashboard.RectEdge) {
var edge = Dashboard.RectEdge[edgeName];
var item = {
edge: edge,
metrics: this._bestMetricsForEdge(this._preferredSize, targetFrame, containerFrame, edge)
};
var preferredIndex = preferredEdges.indexOf(edge);
if (preferredIndex !== -1)
metrics[preferredIndex] = item;
else
metrics.push(item);
}
function area(size)
{
return size.width * size.height;
}
// Find if any of those fit better than the frame for the preferred edge.
var bestEdge = metrics[0].edge;
var bestMetrics = metrics[0].metrics;
for (var i = 1; i < metrics.length; i++) {
var itemMetrics = metrics[i].metrics;
if (area(itemMetrics.contentSize) > area(bestMetrics.contentSize)) {
bestEdge = metrics[i].edge;
bestMetrics = itemMetrics;
}
}
var anchorPoint;
var bestFrame = bestMetrics.frame;
this.frame = bestFrame;
this._edge = bestEdge;
if (this.frame === Dashboard.Rect.ZERO_RECT) {
// The target for the popover is offscreen.
this.dismiss();
} else {
switch (bestEdge) {
case Dashboard.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
anchorPoint = new Dashboard.Point(bestFrame.size.width - Dashboard.Popover.ShadowPadding, targetFrame.midY() - bestFrame.minY());
break;
case Dashboard.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
anchorPoint = new Dashboard.Point(Dashboard.Popover.ShadowPadding, targetFrame.midY() - bestFrame.minY());
break;
case Dashboard.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
anchorPoint = new Dashboard.Point(targetFrame.midX() - bestFrame.minX(), bestFrame.size.height - Dashboard.Popover.ShadowPadding);
break;
case Dashboard.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
anchorPoint = new Dashboard.Point(targetFrame.midX() - bestFrame.minX(), Dashboard.Popover.ShadowPadding);
break;
}
this._element.classList.add(this._cssClassNameForEdge());
this._drawBackground(bestEdge, anchorPoint);
// Make sure content is centered in case either of the dimension is smaller than the minimal bounds.
if (this._preferredSize.width < Dashboard.Popover.MinWidth || this._preferredSize.height < Dashboard.Popover.MinHeight)
this._container.classList.add("center");
else
this._container.classList.remove("center");
}
// Wrap the content in the container so that it's located correctly.
if (this._contentNeedsUpdate) {
this._container.textContent = "";
this._element.replaceChild(this._container, this._content);
this._container.appendChild(this._content);
}
this._contentNeedsUpdate = false;
},
_cssClassNameForEdge: function()
{
switch (this._edge) {
case Dashboard.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
return "arrow-right";
case Dashboard.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
return "arrow-left";
case Dashboard.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
return "arrow-down";
case Dashboard.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
return "arrow-up";
}
console.error("Unknown edge.");
return "arrow-up";
},
_drawBackground: function(edge, anchorPoint)
{
var scaleFactor = window.devicePixelRatio;
var width = this._frame.size.width;
var height = this._frame.size.height;
var scaledWidth = width * scaleFactor;
var scaledHeight = height * scaleFactor;
// Create a scratch canvas so we can draw the popover that will later be drawn into
// the final context with a shadow.
var scratchCanvas = document.createElement("canvas");
scratchCanvas.width = scaledWidth;
scratchCanvas.height = scaledHeight;
var ctx = scratchCanvas.getContext("2d");
ctx.scale(scaleFactor, scaleFactor);
// Bounds of the path don't take into account the arrow, but really only the tight bounding box
// of the content contained within the frame.
var bounds;
var arrowHeight = Dashboard.Popover.AnchorSize.height;
switch (edge) {
case Dashboard.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
bounds = new Dashboard.Rect(0, 0, width - arrowHeight, height);
break;
case Dashboard.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
bounds = new Dashboard.Rect(arrowHeight, 0, width - arrowHeight, height);
break;
case Dashboard.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
bounds = new Dashboard.Rect(0, 0, width, height - arrowHeight);
break;
case Dashboard.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
bounds = new Dashboard.Rect(0, arrowHeight, width, height - arrowHeight);
break;
}
bounds = bounds.inset(Dashboard.Popover.ShadowEdgeInsets);
// Clip the frame.
ctx.fillStyle = "black";
this._drawFrame(ctx, bounds, edge, anchorPoint);
ctx.clip();
// Gradient fill, top-to-bottom.
var fillGradient = ctx.createLinearGradient(0, 0, 0, height);
fillGradient.addColorStop(0, "rgba(255, 255, 255, 0.95)");
fillGradient.addColorStop(1, "rgba(235, 235, 235, 0.95)");
ctx.fillStyle = fillGradient;
ctx.fillRect(0, 0, width, height);
// Stroke.
ctx.strokeStyle = "rgba(0, 0, 0, 0.25)";
ctx.lineWidth = 2;
this._drawFrame(ctx, bounds, edge, anchorPoint);
ctx.stroke();
// Draw the popover into the final context with a drop shadow.
var finalContext = document.getCSSCanvasContext("2d", this._canvasId, scaledWidth, scaledHeight);
finalContext.clearRect(0, 0, scaledWidth, scaledHeight);
finalContext.shadowOffsetX = 1;
finalContext.shadowOffsetY = 1;
finalContext.shadowBlur = 5;
finalContext.shadowColor = "rgba(0, 0, 0, 0.5)";
finalContext.drawImage(scratchCanvas, 0, 0, scaledWidth, scaledHeight);
},
_bestMetricsForEdge: function(preferredSize, targetFrame, containerFrame, edge)
{
var x, y;
var width = preferredSize.width + (Dashboard.Popover.ShadowPadding * 2) + (Dashboard.Popover.ContentPadding * 2);
var height = preferredSize.height + (Dashboard.Popover.ShadowPadding * 2) + (Dashboard.Popover.ContentPadding * 2);
var arrowLength = Dashboard.Popover.AnchorSize.height;
switch (edge) {
case Dashboard.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
width += arrowLength;
x = targetFrame.origin.x - width + Dashboard.Popover.ShadowPadding;
y = targetFrame.origin.y - (height - targetFrame.size.height) / 2;
break;
case Dashboard.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
width += arrowLength;
x = targetFrame.origin.x + targetFrame.size.width - Dashboard.Popover.ShadowPadding;
y = targetFrame.origin.y - (height - targetFrame.size.height) / 2;
break;
case Dashboard.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
height += arrowLength;
x = targetFrame.origin.x - (width - targetFrame.size.width) / 2;
y = targetFrame.origin.y - height + Dashboard.Popover.ShadowPadding;
break;
case Dashboard.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
height += arrowLength;
x = targetFrame.origin.x - (width - targetFrame.size.width) / 2;
y = targetFrame.origin.y + targetFrame.size.height - Dashboard.Popover.ShadowPadding;
break;
}
if (edge === Dashboard.RectEdge.MIN_X || edge === Dashboard.RectEdge.MAX_X) {
if (y < containerFrame.minY())
y = containerFrame.minY();
if (y + height > containerFrame.maxY())
y = containerFrame.maxY() - height;
} else {
if (x < containerFrame.minX())
x = containerFrame.minX();
if (x + width > containerFrame.maxX())
x = containerFrame.maxX() - width;
}
var preferredFrame = new Dashboard.Rect(x, y, width, height);
var bestFrame = preferredFrame.intersectionWithRect(containerFrame);
width = bestFrame.size.width - (Dashboard.Popover.ShadowPadding * 2);
height = bestFrame.size.height - (Dashboard.Popover.ShadowPadding * 2);
switch (edge) {
case Dashboard.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
case Dashboard.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
width -= arrowLength;
break;
case Dashboard.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
case Dashboard.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
height -= arrowLength;
break;
}
return {
frame: bestFrame,
contentSize: new Dashboard.Size(width, height)
};
},
_drawFrame: function(ctx, bounds, anchorEdge, anchorPoint)
{
var r = Dashboard.Popover.CornerRadius;
var arrowHalfLength = Dashboard.Popover.AnchorSize.width / 2;
ctx.beginPath();
switch (anchorEdge) {
case Dashboard.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
ctx.moveTo(bounds.maxX(), bounds.minY() + r);
ctx.lineTo(bounds.maxX(), anchorPoint.y - arrowHalfLength);
ctx.lineTo(anchorPoint.x, anchorPoint.y);
ctx.lineTo(bounds.maxX(), anchorPoint.y + arrowHalfLength);
ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), r);
ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), r);
ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), r);
ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), r);
break;
case Dashboard.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
ctx.moveTo(bounds.minX(), bounds.maxY() - r);
ctx.lineTo(bounds.minX(), anchorPoint.y + arrowHalfLength);
ctx.lineTo(anchorPoint.x, anchorPoint.y);
ctx.lineTo(bounds.minX(), anchorPoint.y - arrowHalfLength);
ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), r);
ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), r);
ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), r);
ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), r);
break;
case Dashboard.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
ctx.moveTo(bounds.maxX() - r, bounds.maxY());
ctx.lineTo(anchorPoint.x + arrowHalfLength, bounds.maxY());
ctx.lineTo(anchorPoint.x, anchorPoint.y);
ctx.lineTo(anchorPoint.x - arrowHalfLength, bounds.maxY());
ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), r);
ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), r);
ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), r);
ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), r);
break;
case Dashboard.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
ctx.moveTo(bounds.minX() + r, bounds.minY());
ctx.lineTo(anchorPoint.x - arrowHalfLength, bounds.minY());
ctx.lineTo(anchorPoint.x, anchorPoint.y);
ctx.lineTo(anchorPoint.x + arrowHalfLength, bounds.minY());
ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), r);
ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), r);
ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), r);
ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), r);
break;
}
ctx.closePath();
},
_finalizePresentation: function()
{
var wasVisible = this.visible;
this._element.classList.remove(Dashboard.Popover.StepInClassName);
this._element.classList.remove(Dashboard.Popover.FadeOutClassName);
this._element.classList.add(Dashboard.Popover.VisibleClassName);
// Make scroll bar flash if present, so that the user sees that scrolling is possible.
// FIXME: Is there a better way?
if (!wasVisible) {
this._container.scrollTop = 1;
this._container.scrollTop = 0;
}
},
_finalizeDismissal: function()
{
window.removeEventListener("mousedown", this, true);
window.removeEventListener("scroll", this, true);
document.body.removeChild(this._element);
document.body.classList.remove(Dashboard.Popover.PreventDocumentScrollingClassName);
this._element.classList.remove(Dashboard.Popover.VisibleClassName);
this._element.classList.remove(Dashboard.Popover.StepInClassName);
this._element.classList.remove(Dashboard.Popover.FadeOutClassName);
this._container.textContent = "";
if (this.delegate && typeof this.delegate.didDismissPopover === "function")
this.delegate.didDismissPopover(this);
}
};