package com.twasyl.slideshowfx.controls; import com.twasyl.slideshowfx.engine.presentation.PresentationEngine; import com.twasyl.slideshowfx.engine.template.configuration.TemplateConfiguration; import com.twasyl.slideshowfx.server.SlideshowFXServer; import com.twasyl.slideshowfx.utils.DialogHelper; import com.twasyl.slideshowfx.utils.PlatformHelper; import javafx.beans.binding.DoubleBinding; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.concurrent.Worker; import javafx.geometry.Pos; import javafx.print.PageOrientation; import javafx.print.Paper; import javafx.print.PrintQuality; import javafx.print.PrinterJob; import javafx.scene.control.ProgressIndicator; import javafx.scene.layout.StackPane; import javafx.scene.web.WebView; import netscape.javascript.JSException; import netscape.javascript.JSObject; import java.util.Base64; import java.util.concurrent.CompletableFuture; import java.util.logging.Level; import java.util.logging.Logger; import static com.twasyl.slideshowfx.global.configuration.GlobalConfiguration.getDefaultCharset; /** * A browser that displays a presentation and provides methods for interacting with the presentation (like go to a given * slide, define the content of a slide, etc). * The browser is composed by a {@link #backendProperty()} which will be set as a member of the displayed page in the * browser under the name returned by {@link TemplateConfiguration#getJsObject()} variable stored in the {@link #presentationProperty()}. * * @author Thierry Wasylczenko * @version 1.1 * @since SlideshowFX 1.0 */ public final class PresentationBrowser extends StackPane { private static final Logger LOGGER = Logger.getLogger(PresentationBrowser.class.getName()); private final BooleanProperty interactionAllowed = new SimpleBooleanProperty(true); private final ObjectProperty<PresentationEngine> presentation = new SimpleObjectProperty<>(); private final ObjectProperty<Object> backend = new SimpleObjectProperty<>(); private final WebView internalBrowser = new WebView(); private final ProgressIndicator progressIndicator = new ProgressIndicator(); public PresentationBrowser() { this.initializeProgressIndicator(); this.initializeBrowser(); this.setAlignment(Pos.TOP_LEFT); this.getChildren().add(this.internalBrowser); } /** * Get the internal browser of this {@link PresentationBrowser}. The internal browser is the {@link WebView} that is * used to display the presentation and is never null. * CAUTION: in order to manipulate the presentation, you should use the methods present in the {@link PresentationBrowser} * class and not the internal browser. * * @return The internal browser of this {@link PresentationBrowser}. */ public WebView getInternalBrowser() { return this.internalBrowser; } /** * The presentation associated to this browser. * * @return The property of the presentation associated to this browser. */ public ObjectProperty<PresentationEngine> presentationProperty() { return presentation; } /** * Get the presentation associated to this browser. * * @return The presentation associated to this browser or {@code null} if it hasn't been defined yet. */ public PresentationEngine getPresentation() { return presentation.get(); } /** * Defines the presentation associated to this browser. * * @param presentation The presentation associated to this browser. */ public void setPresentation(PresentationEngine presentation) { this.presentation.set(presentation); } /** * The backend associated to this browser. The backend is defined as member of the page displayed under the name * returned by the {@link TemplateConfiguration#getJsObject()} method of the current {@link #presentationProperty()}. * * @return The property for backend object that has been defined. */ public ObjectProperty<Object> backendProperty() { return backend; } /** * The backend associated to this browser. The backend is defined as member of the page displayed under the name * returned by the {@link TemplateConfiguration#getJsObject()} method of the current {@link #presentationProperty()}. * * @return The backend object that has been defined or {@code null} if it hasn't been defined yet. */ public Object getBackend() { return backend.get(); } /** * Defines the backend associated to this browser. The backend is defined as member of the page displayed under the * name returned by the {@link TemplateConfiguration#getJsObject()} method of the current {@link #presentationProperty()}. * * @param backend The backend to set as member of the displayed page. */ public void setBackend(Object backend) { this.backend.set(backend); } /** * Initializes the node indicating the status of the page's loading. */ private final void initializeProgressIndicator() { final DoubleBinding size = this.widthProperty().divide(15d); this.progressIndicator.prefWidthProperty().bind(size); this.progressIndicator.minWidthProperty().bind(size); this.progressIndicator.maxWidthProperty().bind(size); this.progressIndicator.prefHeightProperty().bind(size); this.progressIndicator.minHeightProperty().bind(size); this.progressIndicator.maxHeightProperty().bind(size); this.progressIndicator.translateXProperty().bind(this.widthProperty().divide(2d).subtract(this.progressIndicator.widthProperty().divide(2))); this.progressIndicator.translateYProperty().bind(this.heightProperty().divide(2d).subtract(this.progressIndicator.heightProperty().divide(2))); } /** * Initializes the internal browser. */ private final void initializeBrowser() { this.internalBrowser.disableProperty().bind(this.interactionAllowed.not()); this.internalBrowser.getEngine().setJavaScriptEnabled(true); this.internalBrowser.getEngine().getLoadWorker().stateProperty().addListener((stateValue, oldState, newState) -> { if (newState == Worker.State.RUNNING) { this.progressIndicator.setProgress(-1d); this.getChildren().add(this.progressIndicator); } else { this.progressIndicator.setProgress(0); this.getChildren().remove(this.progressIndicator); } PresentationBrowser.this.injectBackend(this.getBackend()); PresentationBrowser.this.injectServer(SlideshowFXServer.getSingleton()); }); this.internalBrowser.getEngine().setOnError(errorEvent -> { LOGGER.log(Level.SEVERE, "An error occurred in the internal browser", errorEvent.getException()); }); this.internalBrowser.getEngine().setOnAlert(event -> { DialogHelper.showAlert("SlideshowFX", event.getData()); }); } /** * Injects the backend object as member of the displayed page only if the given {@code backend} is not {@code null} * as well as the {@link #presentationProperty()}. * The backend is defined under the name returned by the * {@link TemplateConfiguration#getJsObject()} method of the current {@link #presentationProperty()}. * * @param backend The backend object to inject into the page. */ private final void injectBackend(Object backend) { if (backend != null && this.internalBrowser.getEngine().getLoadWorker().getState() == Worker.State.SUCCEEDED && this.getPresentation() != null && this.getPresentation().getTemplateConfiguration() != null && this.getPresentation().getTemplateConfiguration().getJsObject() != null) { JSObject window = (JSObject) this.internalBrowser.getEngine().executeScript("window"); // Only inject the backend if it is not already present final Object member = window.getMember(this.getPresentation().getTemplateConfiguration().getJsObject()); if ("undefined".equals(member)) { window.setMember(PresentationBrowser.this.getPresentation().getTemplateConfiguration().getJsObject(), backend); } } } /** * Injects the {@code server} inside the displayed page under the named returned by the * {@link TemplateConfiguration#getSfxServerObject()} method for the current {@link #presentationProperty()} only if * the given {@code server}is not {@code null}. * * @param server The server to inject within the displayed page. */ private final void injectServer(final SlideshowFXServer server) { if (server != null && this.internalBrowser.getEngine().getLoadWorker().getState() == Worker.State.SUCCEEDED && this.getPresentation() != null && this.getPresentation().getTemplateConfiguration() != null && this.getPresentation().getTemplateConfiguration().getSfxServerObject() != null) { JSObject window = (JSObject) this.internalBrowser.getEngine().executeScript("window"); // Only inject the server if it is not already present final Object member = window.getMember(this.getPresentation().getTemplateConfiguration().getSfxServerObject()); if ("undefined".equals(member)) { window.setMember(this.getPresentation().getTemplateConfiguration().getSfxServerObject(), server); } } } /** * Indicates if the user can interact with the internal browser, meaning click on it and so on. * * @return The property indicating if user interaction is allowed for the internal browser. */ public BooleanProperty interactionAllowedProperty() { return interactionAllowed; } /** * Indicates if the user can interact with the internal browser, meaning click on it and so on. * * @return {@code true} if user interactions are allowed for the internal browser, {@code false} otherwise. */ public boolean isInteractionAllowed() { return interactionAllowed.get(); } /** * Defines if user interactions are allowed for the internal browser. * * @param interactionAllowed {@code true} if user interactions are allowed, {@code false} otherwise. */ public void setInteractionAllowed(boolean interactionAllowed) { this.interactionAllowed.set(interactionAllowed); } /** * Loads the given presentation inside the browser. * * @param presentation The presentation to load. */ public final void loadPresentation(final PresentationEngine presentation) { this.loadPresentationAndDo(presentation, null); } public final void loadPresentationAndDo(final PresentationEngine presentation, Runnable action) { if (presentation != null) { this.presentation.set(presentation); final ChangeListener<Worker.State> stateListener = new ChangeListener<Worker.State>() { @Override public void changed(ObservableValue<? extends Worker.State> observable, Worker.State oldState, Worker.State newState) { if (newState != null && newState == Worker.State.SUCCEEDED) { PresentationBrowser.this.internalBrowser.getEngine().getLoadWorker().stateProperty().removeListener(this); PresentationBrowser.this.injectBackend(PresentationBrowser.this.getBackend()); PresentationBrowser.this.injectServer(SlideshowFXServer.getSingleton()); try { if (action != null) action.run(); } catch (JSException jsex) { LOGGER.log(Level.SEVERE, "Error while executing an action in the internal browser", jsex); } } } }; this.internalBrowser.getEngine().getLoadWorker().stateProperty().addListener(stateListener); this.internalBrowser.getEngine().load(presentation.getConfiguration().getPresentationFile().toURI().toASCIIString()); } } /** * Simply reloads the page displayed in the browser, not necessarily the {@link #presentationProperty()}. * * @return A {@link CompletableFuture} which will be complete when the browser is no more loading. */ public final CompletableFuture<Boolean> reload() { final Worker<Void> loadWorker = this.internalBrowser.getEngine().getLoadWorker(); final CompletableFuture<Boolean> reloadDone = new CompletableFuture<>(); final ChangeListener<Worker.State> stateListener = new ChangeListener<Worker.State>() { @Override public void changed(ObservableValue<? extends Worker.State> state, Worker.State oldState, Worker.State newState) { if (newState != null && newState != Worker.State.RUNNING && newState != Worker.State.SCHEDULED && newState != Worker.State.READY) { loadWorker.stateProperty().removeListener(this); reloadDone.complete(true); } } }; loadWorker.stateProperty().addListener(stateListener); PlatformHelper.run(() -> this.internalBrowser.getEngine().reload()); return reloadDone; } /** * Prints the current page displayed within the internal browser, not necessarily the {@link #presentationProperty()}. * If no printers are installed on the system, an awareness is displayed. */ public final void print() { final PrinterJob job = PrinterJob.createPrinterJob(); if (job != null) { if (job.showPrintDialog(null)) { if (this.getPresentation().getArchive() != null) { final String extension = ".".concat(this.getPresentation().getArchiveExtension()); final int indexOfExtension = this.getPresentation().getArchive().getName().indexOf(extension); final String jobName = this.getPresentation().getArchive().getName().substring(0, indexOfExtension); job.getJobSettings().setJobName(jobName); } job.getJobSettings().setPrintQuality(PrintQuality.HIGH); job.getJobSettings().setPageLayout(job.getPrinter().createPageLayout(Paper.A4, PageOrientation.LANDSCAPE, 0, 0, 0, 0)); this.internalBrowser.getEngine().print(job); job.endJob(); } else { job.cancelJob(); } } else { DialogHelper.showError("No printer", "There is no printer installed on your system."); } } /** * Get the current ID of the slide displayed. The ID is retrieved from the JavaScript method identified by the name * returned by the {@link TemplateConfiguration#getGetCurrentSlideMethod()} method of the current * {@link #presentationProperty()}. * * @return The ID of the slide currently displayed, depending on the implementation of the JavaScript method for getting * it. */ public final String getCurrentSlideId() { final String slideId = (String) this.internalBrowser.getEngine().executeScript(this.getPresentation().getTemplateConfiguration().getGetCurrentSlideMethod() + "();"); return slideId; } /** * Defines the content of an element within a slide of the current {@link #presentationProperty()}. The {@code htmlContent} * must not be Base64 encoded. * The JavaScript method identified by the name returned by the {@link TemplateConfiguration#getContentDefinerMethod()} * of the current {@link #presentationProperty()} will be called to define the content. * * @param slideNumber The number of the slide to define the content for an element. * @param elementName The name of the element to define the content for. * @param htmlContent The HTML content, not Base64 encoded, for the element to define. */ public final void defineContent(final String slideNumber, final String elementName, final String htmlContent) { String clearedContent = Base64.getEncoder().encodeToString(htmlContent.getBytes(getDefaultCharset())); String jsCommand = String.format("%1$s(%2$s, \"%3$s\", '%4$s');", this.getPresentation().getTemplateConfiguration().getContentDefinerMethod(), slideNumber, elementName, clearedContent); this.internalBrowser.getEngine().executeScript(jsCommand); } /** * Updates the output of a console within the presentation. The {@code consoleLine} must not be Base64 encoded. * The JavaScript method identified by the name returned by the * {@link TemplateConfiguration#getUpdateCodeSnippetConsoleMethod()} method of the current {@link #presentationProperty()} * will be called in order to update the console output. * * @param consoleOutputId The ID of the console to update. * @param consoleLine The line to insert in the console output. */ public final void updateCodeSnippetConsole(final String consoleOutputId, final String consoleLine) { this.internalBrowser.getEngine().executeScript( String.format("%1$s('%2$s', '%3$s');", this.getPresentation().getTemplateConfiguration().getUpdateCodeSnippetConsoleMethod(), consoleOutputId, Base64.getEncoder().encodeToString(consoleLine.getBytes(getDefaultCharset())) )); } /** * Go to the slide identified by the given {@code slideId}. * * @param slideId The ID of the slide to go to. */ public void slide(final String slideId) { if (slideId != null) { this.internalBrowser.getEngine().executeScript( String.format( "%1$s('%2$s');", this.getPresentation().getTemplateConfiguration().getGotoSlideMethod(), slideId ) ); } } }