/** * Copyright (C) 2001-2017 by RapidMiner and the contributors * * Complete list of developers available at our web site: * * http://rapidminer.com * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Affero General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see http://www.gnu.org/licenses/. */ package com.rapidminer.gui.tools; import java.awt.Dimension; import java.awt.GraphicsEnvironment; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.awt.event.WindowListener; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; import java.util.logging.Level; import java.util.stream.Stream; import javax.swing.JDialog; import javax.swing.SwingUtilities; import javax.swing.Timer; import com.rapidminer.gui.RapidMinerGUI; import com.rapidminer.tools.LogService; import com.rapidminer.tools.RMUrlHandler; import com.rapidminer.tools.usagestats.ActionStatisticsCollector; import javafx.animation.Interpolator; import javafx.animation.TranslateTransition; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.concurrent.Worker.State; import javafx.embed.swing.JFXPanel; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.CacheHint; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.effect.BlurType; import javafx.scene.effect.DropShadow; import javafx.scene.image.ImageView; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.Border; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Circle; import javafx.scene.text.Font; import javafx.scene.text.FontSmoothingType; import javafx.scene.text.FontWeight; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; import javafx.util.Duration; import netscape.javascript.JSObject; /** * Displays a Browser popup with sliding animations * * <p> * The HTML content must contain an initialize() JS method * </p> * * @author Jonas Wilms-Pfau * @since 7.5.0 * */ public class BrowserPopup extends JDialog implements Supplier<String> { /** * Provides functions invokable within the JavaScript of the web page displayed in the webview. */ public final class CTACallbacks { /** * Opens the given url in the system browser. * * @param url * the url to open */ public void openLink(String url) { if (url != null) { closeWithReason(url); // It is freezing on Linux otherwise SwingUtilities.invokeLater(() -> { RMUrlHandler.openInBrowser(url); }); } } public void close() { closeWithReason(Reason.CLOSED); } public void maybeLater() { closeWithReason(Reason.LATER); } public void closeWithReason(String reason) { // Store the reason reasonQueue.offer(reason); if (!closed.compareAndSet(false, true)) { return; } // Slide out & dispose Platform.runLater(() -> { if (webView != null) { ImageView imageView = new ImageView(webView.snapshot(null, null)); animatedPane.getChildren().set(0, imageView); dropShadow.setBlurType(BlurType.TWO_PASS_BOX); } TranslateTransition slideOut = new TranslateTransition(Duration.millis(TRANSLATION_MS), animatedPane); slideOut.setInterpolator(Interpolator.EASE_IN); slideOut.setByX(translationDistance); slideOut.setOnFinished(e -> dispose()); slideOut.play(); }); } @Override public String toString() { return "Callback interface of RapidMiner Studio. Available functions: " + "openLink('url'), maybeLater(),closeWithReason('reason'), close()"; } } /** * Predefined reasons for the closing of the CTA window. */ private interface Reason { public static final String CLOSED = "closed"; public static final String UNKNOWN = "unknown"; public static final String LATER = "later"; public static final String CANCELED = "canceled"; public static final String JS_INVALID = "js_invalid"; public static final String FAILED_LOADING = "failed_loading"; public static final String TIMEOUT = "loading_timeout"; } private static final long serialVersionUID = 1L; /** the timeout (in milliseconds) until the loading fails */ private static final int TIMEOUT = 15_000; private static final int DEFAULT_WIDTH = 500; private static final int DEFAULT_HEIGHT = 200; /** * Check if the OS supports transparency, if not disable all the animation and shadow and show a * simple dialog */ private static final boolean MODERN_UI = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice() .getDefaultConfiguration().isTranslucencyCapable(); /** Mac OS X requires the fonts very early */ private static final String[] FONT_NAMES = new String[] { "OpenSans.ttf", "OpenSans-Bold.ttf", "OpenSans-BoldItalic.ttf", "OpenSans-ExtraBold.ttf", "OpenSans-ExtraBoldItalic.ttf", "OpenSans-Italic.ttf", "OpenSans-Light.ttf", "OpenSans-LightItalic.ttf", "OpenSans-Semibold.ttf", "OpenSans-SemiboldItalic.ttf", "ionicons.ttf" }; private static final Font[] FONTS = Stream.of(FONT_NAMES) .map(font -> Font.loadFont( BrowserPopup.class.getResource("/com/rapidminer/resources/fonts/" + font).toExternalForm(), 12)) .toArray(s -> new Font[s]); private static final int TRANSLATION_MS = MODERN_UI ? 1_000 : 1; // This is required for the slide-in animation private static final int RIGHT_MARGIN = MODERN_UI ? 100 : 0; // This is required to display the shadow private static final int BORDER_PADDING = MODERN_UI ? 25 : 0; // The shadow is slightly moved downwards private static final int SHADOW_OFFSET_X = 0; private static final int SHADOW_OFFSET_Y = MODERN_UI ? 2 : 0; private static final Color SHADOW_COLOR = Color.color(0, 0, 0, 0.12); private static final int BORDER_SIZE = MODERN_UI ? 1 : 0; private static final Background BORDER_BG = MODERN_UI ? new Background(new BackgroundFill(Color.grayRgb(230), null, null)) : Background.EMPTY; /** ionicons X icon */ private static final String CLOSE_SYMBOL = "\uf2d7"; private static final double CLOSE_RADIUS = 11; private static final Font CLOSE_FONT = Font.font("Ionicons", FontWeight.EXTRA_BOLD, 18); private static final Background CLOSE_BG = new Background(new BackgroundFill(Color.grayRgb(230), null, null)); private static final Background CLOSE_BG_HOVER = new Background(new BackgroundFill(Color.grayRgb(154), null, null)); /** The distance of the close button to the upper right side */ private static final Insets CLOSE_MARGIN = new Insets(10, 15, 0, 0); /** Close with Alt+F4 or the close button in decorated mode */ private static final int CLOSE_OPERATION = MODERN_UI ? DO_NOTHING_ON_CLOSE : DISPOSE_ON_CLOSE; /** Transparent color in overlay mode, white in dialog mode */ private static final java.awt.Color BG_COLOR = MODERN_UI ? new java.awt.Color(0, 0, 0, 0) : java.awt.Color.WHITE; private static final String JS_ROOT = "window"; private static final String JS_NAME = "CTA"; private static final String JS_INITIALIZE = "initialize()"; /** Hack to keep translucent window alive after it was iconified */ private final WindowListener transparencyFix = new WindowAdapter() { @Override public void windowActivated(WindowEvent e) { setBackground(BG_COLOR); } @Override public void windowIconified(WindowEvent e) { setBackground(java.awt.Color.WHITE); } }; /** Distance required for the slide-in / slide-out animation */ private final int translationDistance; private int width; private int height; // Is the bubble ready to display private boolean isLoaded = false; // Was this bubble already animated? private boolean wasAnimated = false; /** See if the dialog is closed */ private final AtomicBoolean closed = new AtomicBoolean(false); /** Queue used to store the reason */ private BlockingQueue<String> reasonQueue = new ArrayBlockingQueue<>(1); private final JFXPanel contentPanel; private Timer failTimer; /** Actual HTML content of the popup */ private final String html; /** Animated Stack Pane */ private final StackPane animatedPane = new StackPane(); /** Used to decrease the quality on transition */ private WebView webView; /** Used to decrease shadow quality for transition */ private final DropShadow dropShadow = new DropShadow(BORDER_PADDING, SHADOW_OFFSET_X, SHADOW_OFFSET_Y, SHADOW_COLOR); /** * Creates a 500x200 Browser Popup * * @param html */ public BrowserPopup(String html) { this(html, DEFAULT_WIDTH, DEFAULT_HEIGHT); } /** * @param onboardingDialog * the parent dialog */ public BrowserPopup(String html, int width, int height) { super(RapidMinerGUI.getMainFrame()); this.width = width; this.height = height; translationDistance = width + RIGHT_MARGIN + BORDER_PADDING; // Hack to keep translucent window alive after it was iconified RapidMinerGUI.getMainFrame().addWindowListener(transparencyFix); this.setLocationRelativeTo(RapidMinerGUI.getMainFrame()); this.setDefaultCloseOperation(CLOSE_OPERATION); this.html = html; this.setUndecorated(MODERN_UI); // Make it transparent getRootPane().setOpaque(!MODERN_UI); getContentPane().setBackground(BG_COLOR); getRootPane().setBackground(BG_COLOR); setBackground(BG_COLOR); this.contentPanel = new JFXPanel(); this.contentPanel.setOpaque(!MODERN_UI); this.contentPanel.setBackground(BG_COLOR); SwingTools.disableClearType(contentPanel); this.add(contentPanel); this.getContentPane() .setPreferredSize(new Dimension(width + RIGHT_MARGIN + BORDER_PADDING, height + 2 * BORDER_PADDING)); this.createScene(); this.pack(); } /** * Waits up to 4 hours for the user to click a button in the BrowserPopup */ @Override public String get() { String reason = Reason.UNKNOWN; if (closed.get() && reasonQueue.isEmpty()) { reason = Reason.CLOSED; } else { try { // Give the user 4 hours to click the button reason = reasonQueue.poll(4, TimeUnit.HOURS); if (reason != null) { // reoffer the reason reasonQueue.offer(reason); } else { reason = Reason.UNKNOWN; } } catch (InterruptedException e) { LogService.getRoot().log(Level.WARNING, "com.rapidminer.gui.tools.BrowserPopup.result.interupted", e); reason = Reason.UNKNOWN; } } return reason; } @Override public void setVisible(boolean visible) { boolean wasVisible = this.isVisible(); super.setVisible(visible); if (!wasVisible && visible) { slideIn(); } } @Override public void dispose() { closed.set(true); SwingUtilities.invokeLater(() -> { try { RapidMinerGUI.getMainFrame().removeWindowListener(transparencyFix); super.dispose(); } catch (NullPointerException npe) { /** * This is known to happen on the JFXPanel, but we can recover from this * * https://bugs.openjdk.java.net/browse/JDK-8089371 * https://bugs.openjdk.java.net/browse/JDK-8098836 */ LogService.getRoot().log(Level.INFO, "com.rapidminer.gui.tools.BrowserPopup.dispose.failed", npe); dispatchEvent(new WindowEvent(this, WindowEvent.WINDOW_CLOSED)); setVisible(false); } }); } /** * Creates scene with the webview and the close button */ private void createScene() { // make it possible to create the dialog a second time Platform.setImplicitExit(false); Platform.runLater(() -> { // Transparent root pane BorderPane rootPane = new BorderPane(); rootPane.setBackground(Background.EMPTY); // Add padding to see the Shadow rootPane.setPadding(new Insets(BORDER_PADDING - SHADOW_OFFSET_Y, RIGHT_MARGIN, BORDER_PADDING + SHADOW_OFFSET_Y, BORDER_PADDING)); rootPane.setCenter(animatedPane); // Enable cache for smoother animation animatedPane.setCache(true); animatedPane.setCacheHint(CacheHint.SPEED); animatedPane.setCacheShape(true); /** Just fill the whole pane and add a background */ animatedPane.setPadding(new Insets(BORDER_SIZE)); animatedPane.setBackground(BORDER_BG); // Reduce dropShadow Quality dropShadow.setBlurType(BlurType.TWO_PASS_BOX); animatedPane.setEffect(dropShadow); webView = new WebView(); Button closeButton = createCloseButton(); // Show button only on hover closeButton.opacityProperty() .bind(Bindings.when(webView.hoverProperty().or(closeButton.hoverProperty())).then(1).otherwise(0)); // Reduce WebView Quality webView.setContextMenuEnabled(false); webView.setFontSmoothingType(FontSmoothingType.GRAY); webView.setCache(true); webView.setCacheHint(CacheHint.SPEED); // Translate the animatedPane out of sight animatedPane.setTranslateX(translationDistance); // The webView is overlayed by the close button animatedPane.getChildren().addAll(webView, closeButton); // move close button to the upper right corner StackPane.setAlignment(closeButton, Pos.TOP_RIGHT); StackPane.setMargin(closeButton, CLOSE_MARGIN); // Create a transparent root scene Scene rootScene = new Scene(rootPane, Color.TRANSPARENT); contentPanel.setScene(rootScene); // prepare the browser configureWebEngine(webView); } ); } /** * Creates a close button * * Round button with a X inside * * @return */ private Button createCloseButton() { Button closeButton = new Button(CLOSE_SYMBOL); closeButton.setCancelButton(true); closeButton.setDefaultButton(false); closeButton.setShape(new Circle(CLOSE_RADIUS)); closeButton.setMinSize(2 * CLOSE_RADIUS, 2 * CLOSE_RADIUS); closeButton.setBorder(Border.EMPTY); closeButton.setBackground(CLOSE_BG); closeButton.setFont(CLOSE_FONT); closeButton.setPadding(Insets.EMPTY); closeButton.setTextFill(Color.WHITE); closeButton.setVisible(MODERN_UI); // Hover effect closeButton.backgroundProperty() .bind(Bindings.when(closeButton.hoverProperty()).then(CLOSE_BG_HOVER).otherwise(CLOSE_BG)); // Close action closeButton.setOnAction((ev) -> new CTACallbacks().closeWithReason(Reason.CLOSED)); return closeButton; } /** * Configures the engine behind the webView. */ private void configureWebEngine(WebView webView) { final WebEngine webEngine = webView.getEngine(); webEngine.getLoadWorker().stateProperty().addListener(new ChangeListener<State>() { private boolean hasFallbackTimer = false; @Override public void changed(ObservableValue<? extends State> ov, State oldState, State newState) { if (oldState == newState) { return; } switch (newState) { case SUCCEEDED: JSObject win = (JSObject) webEngine.executeScript(JS_ROOT); win.setMember(JS_NAME, new CTACallbacks()); try { // Check JS initialization webEngine.executeScript(JS_INITIALIZE); // Stop the timer if (failTimer != null) { failTimer.stop(); } // Translate the webView in isLoaded = true; slideIn(); } catch (RuntimeException e) { // initialize method does not exist, fail silently if (failTimer != null) { failTimer.stop(); } new CTACallbacks().closeWithReason(Reason.JS_INVALID); logCtaFailure(Reason.JS_INVALID); } break; case FAILED: if (failTimer != null) { failTimer.stop(); } new CTACallbacks().closeWithReason(Reason.FAILED_LOADING); logCtaFailure(Reason.FAILED_LOADING); break; case RUNNING: if (!hasFallbackTimer) { hasFallbackTimer = true; failTimer = new Timer(TIMEOUT, new ActionListener() { @Override public void actionPerformed(ActionEvent e) { Platform.runLater(() -> { new CTACallbacks().closeWithReason(Reason.TIMEOUT); logCtaFailure(Reason.TIMEOUT); }); } }); failTimer.setRepeats(false); failTimer.start(); } break; case CANCELLED: new CTACallbacks().closeWithReason(Reason.CANCELED); logCtaFailure(Reason.CANCELED); break; case READY: case SCHEDULED: default: break; } } }); webEngine.loadContent(html); } /** * Slide In animation */ private void slideIn() { if (this.isVisible() && isLoaded && !wasAnimated) { Platform.runLater(() -> { animatedPane.setTranslateX(translationDistance); TranslateTransition slideIn = new TranslateTransition(Duration.millis(TRANSLATION_MS), animatedPane); slideIn.setInterpolator(Interpolator.EASE_OUT); slideIn.setByX(-1 * translationDistance); slideIn.setOnFinished(e -> Platform.runLater(() -> { wasAnimated = true; if (webView != null) { dropShadow.setBlurType(BlurType.GAUSSIAN); webView.setFontSmoothingType(FontSmoothingType.LCD); webView.setCacheHint(CacheHint.QUALITY); } })); slideIn.play(); }); } } /** * Logs CTA externalization statistics. */ private void logCtaFailure(String value) { ActionStatisticsCollector.INSTANCE.log(ActionStatisticsCollector.TYPE_CTA, ActionStatisticsCollector.VALUE_CTA_FAILURE, value); } }