/* * Copyright (C) 2013, 2015 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. */ WI.Popover = class Popover extends WI.Object { constructor(delegate) { super(); this.delegate = delegate; this._edge = null; this._frame = new WI.Rect; this._content = null; this._targetFrame = new WI.Rect; this._anchorPoint = new WI.Point; this._preferredEdges = null; this._resizeHandler = null; this._contentNeedsUpdate = false; this._dismissing = false; this._element = document.createElement("div"); this._element.className = "popover"; this._element.addEventListener("transitionend", this, true); this._container = this._element.appendChild(document.createElement("div")); this._container.className = "container"; this._drawBackgroundAnimationIdentifier = undefined; } // Public get element() { return this._element; } get visible() { return this._element.parentNode === document.body && !this._element.classList.contains(WI.Popover.FadeOutClassName); } get frame() { return this._frame; } set frame(frame) { this._element.style.left = frame.minX() + "px"; this._element.style.top = frame.minY() + "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; } set content(content) { if (content === this._content) return; this._content = content; this._contentNeedsUpdate = true; if (this.visible) this._update(true); } set windowResizeHandler(resizeHandler) { console.assert(typeof resizeHandler === "function"); this._resizeHandler = resizeHandler; } resize() { if (this.visible && this._resizeHandler) this._resizeHandler(); } update(shouldAnimate = true) { if (!this.visible) return; var previouslyFocusedElement = document.activeElement; this._contentNeedsUpdate = true; this._update(shouldAnimate); if (previouslyFocusedElement) previouslyFocusedElement.focus(); } present(targetFrame, preferredEdges, {updateContent, shouldAnimate} = {}) { this._targetFrame = targetFrame; this._preferredEdges = preferredEdges; if (!this._content) return; this._addListenersIfNeeded(); if (updateContent && this.visible) this.update(shouldAnimate); else this._update(shouldAnimate); } presentNewContentWithFrame(content, targetFrame, preferredEdges) { this._content = content; this._contentNeedsUpdate = true; this._targetFrame = targetFrame; this._preferredEdges = preferredEdges; this._addListenersIfNeeded(); var shouldAnimate = this.visible; this._update(shouldAnimate); } dismiss() { if (this._dismissing || this._element.parentNode !== document.body) return; this._dismissing = true; console.assert(this._isListeningForPopoverEvents); this._isListeningForPopoverEvents = false; window.removeEventListener("mousedown", this, true); window.removeEventListener("scroll", this, true); window.removeEventListener("resize", this, true); window.removeEventListener("keypress", this, true); this._prefersDarkColorSchemeMediaQueryList.removeListener(this._boundUpdate); WI.quickConsole.keyboardShortcutDisabled = false; this._element.classList.add(WI.Popover.FadeOutClassName); if (this.delegate && typeof this.delegate.willDismissPopover === "function") this.delegate.willDismissPopover(this); } handleEvent(event) { switch (event.type) { case "mousedown": case "scroll": if (!this._element.contains(event.target) && !event.target.closest("." + WI.Popover.IgnoreAutoDismissClassName) && !event[WI.Popover.EventPreventDismissSymbol]) { this.dismiss(); } break; case "resize": this.resize(); break; case "keypress": if (event.keyCode === WI.KeyboardShortcut.Key.Escape.keyCode) this.dismiss(); break; case "transitionend": if (event.target === this._element) { document.body.removeChild(this._element); this._element.classList.remove(WI.Popover.FadeOutClassName); this._container.textContent = ""; if (this.delegate && typeof this.delegate.didDismissPopover === "function") this.delegate.didDismissPopover(this); this._dismissing = false; break; } break; } } // Private _update(shouldAnimate) { if (shouldAnimate) var previousEdge = this._edge; var targetFrame = this._targetFrame; var preferredEdges = this._preferredEdges; // Ensure our element is on display so that its metrics can be resolved // or interrupt any pending transition to remove it from display. if (this._element.parentNode !== document.body) document.body.appendChild(this._element); else this._element.classList.remove(WI.Popover.FadeOutClassName); this._dismissing = false; if (this._edge !== null) this._element.classList.remove(this._cssClassNameForEdge()); 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"); // Add the content in place of the wrapper to get the raw metrics. this._container.replaceWith(this._content); // Get the ideal size for the popover to fit its content. var popoverBounds = this._element.getBoundingClientRect(); this._preferredSize = new WI.Size(Math.ceil(popoverBounds.width), Math.ceil(popoverBounds.height)); } var titleBarOffset = WI.undockedTitleAreaHeight(); var containerFrame = new WI.Rect(0, titleBarOffset, window.innerWidth, window.innerHeight - titleBarOffset); // The frame of the window with a little inset to make sure we have room for shadows. containerFrame = containerFrame.inset(WI.Popover.ShadowEdgeInsets); // Work out the metrics for all edges. var metrics = new Array(preferredEdges.length); for (var edgeName in WI.RectEdge) { var edge = WI.RectEdge[edgeName]; var item = { 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 Math.max(0, size.width) * Math.max(0, 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; } } console.assert(area(bestMetrics.contentSize) > 0); var anchorPoint; var bestFrame = bestMetrics.frame.round(); this._edge = bestEdge; if (bestFrame === WI.Rect.ZERO_RECT) { // The target for the popover is offscreen. this.dismiss(); } else { switch (bestEdge) { case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right. anchorPoint = new WI.Point(bestFrame.size.width - WI.Popover.ShadowPadding, targetFrame.midY() - bestFrame.minY()); break; case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left. anchorPoint = new WI.Point(WI.Popover.ShadowPadding, targetFrame.midY() - bestFrame.minY()); break; case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down. anchorPoint = new WI.Point(targetFrame.midX() - bestFrame.minX(), bestFrame.size.height - WI.Popover.ShadowPadding); break; case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up. anchorPoint = new WI.Point(targetFrame.midX() - bestFrame.minX(), WI.Popover.ShadowPadding); break; } this._element.classList.add(this._cssClassNameForEdge()); if (shouldAnimate && this._edge === previousEdge) this._animateFrame(bestFrame, anchorPoint); else { this.frame = bestFrame; this._setAnchorPoint(anchorPoint); this._drawBackground(); } // Make sure content is centered in case either of the dimension is smaller than the minimal bounds. if (this._preferredSize.width < WI.Popover.MinWidth || this._preferredSize.height < WI.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._content.replaceWith(this._container); this._container.appendChild(this._content); } this._contentNeedsUpdate = false; } _cssClassNameForEdge() { switch (this._edge) { case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right. return "arrow-right"; case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left. return "arrow-left"; case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down. return "arrow-down"; case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up. return "arrow-up"; } console.error("Unknown edge."); return "arrow-up"; } _setAnchorPoint(anchorPoint) { anchorPoint.x = Math.floor(anchorPoint.x); anchorPoint.y = Math.floor(anchorPoint.y); this._anchorPoint = anchorPoint; } _animateFrame(toFrame, toAnchor) { var startTime = Date.now(); var duration = 350; var epsilon = 1 / (200 * duration); var spline = new WI.CubicBezier(0.25, 0.1, 0.25, 1); var fromFrame = this._frame.copy(); var fromAnchor = this._anchorPoint.copy(); function animatedValue(from, to, progress) { return from + (to - from) * progress; } function drawBackground() { var progress = spline.solve(Math.min((Date.now() - startTime) / duration, 1), epsilon); this.frame = new WI.Rect( animatedValue(fromFrame.minX(), toFrame.minX(), progress), animatedValue(fromFrame.minY(), toFrame.minY(), progress), animatedValue(fromFrame.size.width, toFrame.size.width, progress), animatedValue(fromFrame.size.height, toFrame.size.height, progress) ).round(); this._setAnchorPoint(new WI.Point( animatedValue(fromAnchor.x, toAnchor.x, progress), animatedValue(fromAnchor.y, toAnchor.y, progress) )); this._drawBackground(); if (progress < 1) this._drawBackgroundAnimationIdentifier = requestAnimationFrame(drawBackground.bind(this)); } drawBackground.call(this); } _drawBackground() { if (this._drawBackgroundAnimationIdentifier) { cancelAnimationFrame(this._drawBackgroundAnimationIdentifier); this._drawBackgroundAnimationIdentifier = undefined; } let scaleFactor = window.devicePixelRatio; let width = this._frame.size.width; let height = this._frame.size.height; let scaledWidth = width * scaleFactor; let scaledHeight = height * 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. let bounds; switch (this._edge) { case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right. bounds = new WI.Rect(0, 0, width - WI.Popover.AnchorSize, height); break; case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left. bounds = new WI.Rect(WI.Popover.AnchorSize, 0, width - WI.Popover.AnchorSize, height); break; case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down. bounds = new WI.Rect(0, 0, width, height - WI.Popover.AnchorSize); break; case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up. bounds = new WI.Rect(0, WI.Popover.AnchorSize, width, height - WI.Popover.AnchorSize); break; } bounds = bounds.inset(WI.Popover.ShadowEdgeInsets); let computedStyle = window.getComputedStyle(this._element, null); let context = document.getCSSCanvasContext("2d", "popover", scaledWidth, scaledHeight); context.clearRect(0, 0, scaledWidth, scaledHeight); function isolate(callback) { context.save(); callback(); context.restore(); } isolate(() => { context.scale(scaleFactor, scaleFactor); this._drawFrame(context, bounds, this._edge, this._anchorPoint); isolate(() => { context.shadowBlur = 4; context.shadowColor = computedStyle.getPropertyValue("--popover-shadow-color").trim(); context.strokeStyle = computedStyle.getPropertyValue("--popover-border-color").trim(); context.lineWidth = 2; context.stroke(); }); isolate(() => { context.fillStyle = computedStyle.getPropertyValue("--popover-background-color").trim(); context.fill(); }); }); } _bestMetricsForEdge(preferredSize, targetFrame, containerFrame, edge) { var x, y; var width = preferredSize.width + (WI.Popover.ShadowPadding * 2) + (WI.Popover.ContentPadding * 2); var height = preferredSize.height + (WI.Popover.ShadowPadding * 2) + (WI.Popover.ContentPadding * 2); switch (edge) { case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right. width += WI.Popover.AnchorSize; x = targetFrame.origin.x - width + WI.Popover.ShadowPadding; y = targetFrame.origin.y - (height - targetFrame.size.height) / 2; break; case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left. width += WI.Popover.AnchorSize; x = targetFrame.origin.x + targetFrame.size.width - WI.Popover.ShadowPadding; y = targetFrame.origin.y - (height - targetFrame.size.height) / 2; break; case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down. height += WI.Popover.AnchorSize; x = targetFrame.origin.x - (width - targetFrame.size.width) / 2; y = targetFrame.origin.y - height + WI.Popover.ShadowPadding; break; case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up. height += WI.Popover.AnchorSize; x = targetFrame.origin.x - (width - targetFrame.size.width) / 2; y = targetFrame.origin.y + targetFrame.size.height - WI.Popover.ShadowPadding; break; } if (edge !== WI.RectEdge.MIN_X && x < containerFrame.minX()) x = containerFrame.minX(); if (edge !== WI.RectEdge.MAX_X && x + width > containerFrame.maxX()) x = containerFrame.maxX() - width; if (edge !== WI.RectEdge.MIN_Y && y < containerFrame.minY()) y = containerFrame.minY(); if (edge !== WI.RectEdge.MAX_Y && y + height > containerFrame.maxY()) y = containerFrame.maxY() - height; var preferredFrame = new WI.Rect(x, y, width, height); var bestFrame = preferredFrame.intersectionWithRect(containerFrame); width = bestFrame.size.width - (WI.Popover.ShadowPadding * 2); height = bestFrame.size.height - (WI.Popover.ShadowPadding * 2); switch (edge) { case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right. case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left. width -= WI.Popover.AnchorSize; break; case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down. case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up. height -= WI.Popover.AnchorSize; break; } return { frame: bestFrame, contentSize: new WI.Size(width, height) }; } _drawFrame(ctx, bounds, anchorEdge) { let cornerRadius = WI.Popover.CornerRadius; let anchorPoint = this._anchorPoint; // Prevent the arrow from being positioned against one of the popover's rounded corners. let arrowPadding = cornerRadius + WI.Popover.AnchorSize; if (anchorEdge === WI.RectEdge.MIN_Y || anchorEdge === WI.RectEdge.MAX_Y) anchorPoint.x = Number.constrain(anchorPoint.x, bounds.minX() + arrowPadding, bounds.maxX() - arrowPadding); else anchorPoint.y = Number.constrain(anchorPoint.y, bounds.minY() + arrowPadding, bounds.maxY() - arrowPadding); ctx.beginPath(); switch (anchorEdge) { case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right. ctx.moveTo(bounds.maxX(), bounds.minY() + cornerRadius); ctx.lineTo(bounds.maxX(), anchorPoint.y - WI.Popover.AnchorSize); ctx.lineTo(anchorPoint.x, anchorPoint.y); ctx.lineTo(bounds.maxX(), anchorPoint.y + WI.Popover.AnchorSize); ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), cornerRadius); ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), cornerRadius); ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), cornerRadius); ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), cornerRadius); break; case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left. ctx.moveTo(bounds.minX(), bounds.maxY() - cornerRadius); ctx.lineTo(bounds.minX(), anchorPoint.y + WI.Popover.AnchorSize); ctx.lineTo(anchorPoint.x, anchorPoint.y); ctx.lineTo(bounds.minX(), anchorPoint.y - WI.Popover.AnchorSize); ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), cornerRadius); ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), cornerRadius); ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), cornerRadius); ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), cornerRadius); break; case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down. ctx.moveTo(bounds.maxX() - cornerRadius, bounds.maxY()); ctx.lineTo(anchorPoint.x + WI.Popover.AnchorSize, bounds.maxY()); ctx.lineTo(anchorPoint.x, anchorPoint.y); ctx.lineTo(anchorPoint.x - WI.Popover.AnchorSize, bounds.maxY()); ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), cornerRadius); ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), cornerRadius); ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), cornerRadius); ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), cornerRadius); break; case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up. ctx.moveTo(bounds.minX() + cornerRadius, bounds.minY()); ctx.lineTo(anchorPoint.x - WI.Popover.AnchorSize, bounds.minY()); ctx.lineTo(anchorPoint.x, anchorPoint.y); ctx.lineTo(anchorPoint.x + WI.Popover.AnchorSize, bounds.minY()); ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), cornerRadius); ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), cornerRadius); ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), cornerRadius); ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), cornerRadius); break; } ctx.closePath(); } _addListenersIfNeeded() { if (!this._isListeningForPopoverEvents) { this._isListeningForPopoverEvents = true; window.addEventListener("mousedown", this, true); window.addEventListener("scroll", this, true); window.addEventListener("resize", this, true); window.addEventListener("keypress", this, true); if (!this._boundUpdate) this._boundUpdate = this._update.bind(this); if (!this._prefersDarkColorSchemeMediaQueryList) this._prefersDarkColorSchemeMediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); this._prefersDarkColorSchemeMediaQueryList.addListener(this._boundUpdate); WI.quickConsole.keyboardShortcutDisabled = true; } } }; WI.Popover.FadeOutClassName = "fade-out"; WI.Popover.CornerRadius = 5; WI.Popover.MinWidth = 40; WI.Popover.MinHeight = 40; WI.Popover.ShadowPadding = 5; WI.Popover.ContentPadding = 5; WI.Popover.AnchorSize = 11; WI.Popover.ShadowEdgeInsets = new WI.EdgeInsets(WI.Popover.ShadowPadding); WI.Popover.IgnoreAutoDismissClassName = "popover-ignore-auto-dismiss"; WI.Popover.EventPreventDismissSymbol = Symbol("popover-event-prevent-dismiss");