package com.twasyl.slideshowfx.controllers;
import com.twasyl.slideshowfx.content.extension.IContentExtension;
import com.twasyl.slideshowfx.controls.PresentationBrowser;
import com.twasyl.slideshowfx.controls.PresentationVariablesPanel;
import com.twasyl.slideshowfx.controls.SlideContentEditor;
import com.twasyl.slideshowfx.engine.presentation.PresentationEngine;
import com.twasyl.slideshowfx.engine.presentation.Presentations;
import com.twasyl.slideshowfx.engine.presentation.configuration.Slide;
import com.twasyl.slideshowfx.engine.presentation.configuration.SlideElement;
import com.twasyl.slideshowfx.global.configuration.GlobalConfiguration;
import com.twasyl.slideshowfx.markup.IMarkup;
import com.twasyl.slideshowfx.markup.MarkupManager;
import com.twasyl.slideshowfx.osgi.OSGiManager;
import com.twasyl.slideshowfx.snippet.executor.CodeSnippet;
import com.twasyl.slideshowfx.snippet.executor.ISnippetExecutor;
import com.twasyl.slideshowfx.utils.DialogHelper;
import com.twasyl.slideshowfx.utils.PlatformHelper;
import com.twasyl.slideshowfx.utils.beans.Pair;
import com.twasyl.slideshowfx.utils.beans.binding.FilenameBinding;
import de.jensd.fx.glyphs.GlyphIcon;
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.adapter.JavaBeanObjectProperty;
import javafx.beans.property.adapter.JavaBeanObjectPropertyBuilder;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.WritableImage;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import org.xml.sax.SAXException;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerException;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.Base64;
import java.util.Iterator;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* This class is the controller of the {@code PresentationView.fxml} file. It defines all actions possible inside the view
* represented by the FXML.
*
* @author Thierry Wasyczenko
* @version 1.2
* @since SlideshowFX 1.0
*/
public class PresentationViewController implements Initializable {
private static final Logger LOGGER = Logger.getLogger(PresentationViewController.class.getName());
private SlideshowFXController parent;
private PresentationEngine presentationEngine;
private final ReadOnlyStringProperty presentationName = new SimpleStringProperty();
private final ReadOnlyBooleanProperty presentationModified = new SimpleBooleanProperty(false);
@FXML
private PresentationBrowser browser;
@FXML
private TextField slideNumber;
@FXML
private TextField fieldName;
@FXML
private HBox markupContentTypeBox;
@FXML
private ToolBar contentExtensionToolBar;
@FXML
private ToggleGroup markupContentType = new ToggleGroup();
@FXML
private SlideContentEditor contentEditor;
@FXML
private Button defineContent;
/* All methods called by the FXML */
/**
* This method is called by the <code>Define</code> button of the FXML. The selected syntax is retrieved as well as the content.
* The treatment is then delegated to the {@link #updateSlide(IMarkup, String)} method.
*
* @param event
* @throws TransformerException
* @throws IOException
* @throws ParserConfigurationException
* @throws SAXException
*/
@FXML
private void updateSlideWithText(ActionEvent event) throws TransformerException, IOException, ParserConfigurationException, SAXException {
this.updateSlide();
}
/**
* Define and manages variables that are available for the presentation. Variable allow to insert elements which
* values will be replaced inside the presentation.
*
* @param event The source event calling this method.
*/
@FXML
private void definePresentationVariables(ActionEvent event) {
final PresentationVariablesPanel variablesPanel = new PresentationVariablesPanel(this.presentationEngine.getConfiguration());
final ButtonType insert = new ButtonType("Insert", ButtonBar.ButtonData.OTHER);
final ButtonType answer = DialogHelper.showDialog("Insert a variable", variablesPanel, ButtonType.CANCEL, insert, ButtonType.OK);
// Insert the token inside the editor
if (answer != null && answer == insert) {
final Pair<String, String> variable = variablesPanel.getSelectedVariable();
if (variable != null)
this.contentEditor.appendContentEditorValue(String.format("${%1$s}", variable.getKey()));
}
// If cancel wasn't clicked, updates all variables in the presentation and updates it the presentation file
if (answer != ButtonType.CANCEL) {
this.presentationEngine.getConfiguration().setVariables(variablesPanel.getVariables());
this.presentationEngine.getConfiguration()
.getSlides()
.forEach(slide -> this.presentationEngine.getConfiguration().updateSlideInDocument(slide));
this.presentationEngine.savePresentationFile();
this.reloadPresentationBrowser();
}
}
/**
* This method updates a slide of the presentation. The <code>markup</code> and the <code>originalContent</code> are
* deduced from the user interface. If all parameters can be deduced, then {@link #updateSlide(IMarkup, String)} is
* called, otherwise nothing is performed.
*
* @throws TransformerException
* @throws IOException
* @throws ParserConfigurationException
* @throws SAXException
*/
private void updateSlide() throws TransformerException, IOException, ParserConfigurationException, SAXException {
RadioButton selectedMarkup = (RadioButton) this.markupContentType.getSelectedToggle();
if (selectedMarkup != null) {
this.updateSlide((IMarkup) selectedMarkup.getUserData(), this.contentEditor.getContentEditorValue());
}
}
/**
* This method updates a slide of the presentation. It takes the <code>markup</code> to convert the <code>originalContent</code>
* in HTML and then the slide element is updated. The presentation is then saved temporary.
* The content is send to the page by calling the {@link com.twasyl.slideshowfx.engine.template.configuration.TemplateConfiguration#getContentDefinerMethod()}
* with the HTML content converted in Base64.
* A screenshot of the slide is taken to update the menu of available slides.
*
* @param markup The markup with which the new content was generated.
* @param originalContent The original content, in Base64, with which the slide will be updated.
* @throws TransformerException
* @throws IOException
* @throws ParserConfigurationException
* @throws SAXException
*/
private void updateSlide(final IMarkup markup, final String originalContent) throws TransformerException, IOException, ParserConfigurationException, SAXException {
final String elementId = String.format("%1$s-%2$s", this.slideNumber.getText(), this.fieldName.getText());
String htmlContent = markup.convertAsHtml(originalContent);
// Update the SlideElement
final Slide slideToUpdate = this.presentationEngine.getConfiguration().getSlideByNumber(this.slideNumber.getText());
slideToUpdate.updateElement(elementId, markup.getCode(), originalContent, htmlContent);
this.presentationEngine.getConfiguration().updateSlideInDocument(slideToUpdate);
this.presentationEngine.savePresentationFile();
// Clear the HTML of any variables
htmlContent = slideToUpdate.getElement(elementId).getClearedHtmlContent(this.presentationEngine.getConfiguration().getVariables());
this.browser.defineContent(this.slideNumber.getText(), this.fieldName.getText(), htmlContent);
// Take a thumbnail of the slide
WritableImage thumbnail = this.browser.snapshot(null, null);
this.presentationEngine.getConfiguration().updateSlideThumbnail(this.slideNumber.getText(), thumbnail);
if (this.parent != null) this.parent.updateSlideSplitMenu();
this.presentationEngine.setModifiedSinceLatestSave(true);
}
/**
* Update the JavaFX UI with the data from the element that has been clicked in the HTML page.
*
* @param slideNumber The slide number of the slide that has been clicked in the HTML page
* @param field The field of the slide that has been clicked in the HTML page.
* @param currentElementContent The current content of the element clicked in the HTML page.
*/
public void prefillContentDefinition(String slideNumber, String field, String currentElementContent) {
this.slideNumber.setText(slideNumber);
this.fieldName.setText(field);
final Slide slide = this.presentationEngine.getConfiguration().getSlideByNumber(slideNumber);
if (slide != null) {
final SlideElement element = slide.getElement(slideNumber + "-" + field);
/**
* Prefill the content either with the element's content if it is not null, either with the given
* <code>currentElementContent</code>.
*/
if (element != null) {
/**
* Prefill the content with either the original content is it is still supported either
* the HTML content.
*/
if (MarkupManager.isContentSupported(element.getOriginalContentCode())) {
this.contentEditor.setContentEditorValue(element.getOriginalContent());
} else {
this.contentEditor.setContentEditorValue(element.getHtmlContent());
}
this.selectMarkupRadioButton(element.getOriginalContentCode());
} else {
final String decodedContent = new String(Base64.getDecoder().decode(currentElementContent), GlobalConfiguration.getDefaultCharset());
this.contentEditor.setContentEditorValue(decodedContent);
this.selectMarkupRadioButton(null);
this.contentEditor.selectAll();
}
this.contentEditor.requestFocus();
} else {
LOGGER.info(String.format("Prefill information for the field %1$s of slide #%2$s is impossible: the slide is not found", field, slideNumber));
}
}
/**
* This method is called by the presentation in order to execute a code snippet. The executor is identified by the
* {@code snippetExecutorCode} and retrieved in the OSGi context to get the {@link ISnippetExecutor}
* instance that will execute the code.
* The code to execute is passed to this method in Base64 using the {@code base64CodeSnippet} parameter. The execution
* result will be pushed back to the presentation in the HTML element {@code consoleOutputId}.
*
* @param snippetExecutorCode The unique identifier of the executor that will execute the code.
* @param base64CodeSnippet The code snippet to execute, given in Base64.
* @param consoleOutputId The HTML element that will be updated with the execution result.
*/
public void executeCodeSnippet(final String snippetExecutorCode, final String base64CodeSnippet, final String consoleOutputId) {
if (snippetExecutorCode != null) {
final Optional<ISnippetExecutor> snippetExecutor = OSGiManager.getInstance().getInstalledServices(ISnippetExecutor.class)
.stream()
.filter(executor -> snippetExecutorCode.equals(executor.getCode()))
.findFirst();
if (snippetExecutor.isPresent()) {
final String decodedString = new String(Base64.getDecoder().decode(base64CodeSnippet), GlobalConfiguration.getDefaultCharset());
final CodeSnippet codeSnippetDecoded = CodeSnippet.toObject(decodedString);
final ObservableList<String> consoleOutput = snippetExecutor.get().execute(codeSnippetDecoded);
consoleOutput.addListener((ListChangeListener<String>) change -> {
// Push the execution result to the presentation.
PlatformHelper.run(() -> {
while (change.next()) {
if (change.wasAdded()) {
change.getAddedSubList()
.stream()
.forEach(line -> this.browser.updateCodeSnippetConsole(consoleOutputId, line));
}
}
change.reset();
});
});
}
}
}
/**
* Creates a RadioButton for the given markup so the user will be able to select the new syntax. The RadioButton is
* added to the panel of markups as well as in the ToggleGroup for all markups.
* Note that the RadioButton will not request focus when it is clicked. This avoid the cursor to leave an eventual
* text edition area.
*
* @param markup The markup to create the RadioButton for
* @return The created RadioButton.
*/
private RadioButton createRadioButtonForMakup(IMarkup markup) {
final RadioButton button = new RadioButton(markup.getName()) {
@Override
public void requestFocus() {
// Avoid the button to get the focus. So if the cursor is in the editor it won't loose the focus
}
};
button.setUserData(markup);
markupContentType.getToggles().add(button);
markupContentTypeBox.getChildren().add(button);
return button;
}
/**
* Creates a Button for the given content extension so the user will be able to insert new type of content in a slide.
* The Button is added to the ToolBar of content extensions.
*
* @param contentExtension The content extension to create the Button for.
* @return The created Button.
*/
private Button createButtonForContentExtension(final IContentExtension contentExtension) {
final Button button = new Button();
button.setUserData(contentExtension);
button.setTooltip(new Tooltip(contentExtension.getToolTip()));
button.getStyleClass().add("image");
final GlyphIcon icon = new FontAwesomeIconView();
icon.setGlyphName(contentExtension.getIcon().name());
icon.setGlyphSize(20);
icon.setGlyphStyle("-fx-fill: app-color-orange;");
button.setGraphic(icon);
button.setOnAction(event -> {
final ButtonType response = DialogHelper.showCancellableDialog(contentExtension.getTitle(), contentExtension.getUI());
if (response != null && response == ButtonType.OK) {
final String content = contentExtension.buildContentString(this.markupContentType.getSelectedToggle() != null ?
(IMarkup) this.markupContentType.getSelectedToggle().getUserData() :
null);
if (content != null) {
this.contentEditor.appendContentEditorValue(content);
contentExtension.extractResources(this.presentationEngine.getTemplateConfiguration().getResourcesDirectory());
contentExtension.getResources()
.stream()
.forEach(this.presentationEngine::addCustomResource);
}
}
});
this.contentExtensionToolBar.getItems().add(button);
return button;
}
/**
* Select the RadioButton corresponding to the given <code>contentCode</code>. If the <code>contentCode</code> is null,
* every RadioButton is unselected.
*
* @param contentCode
*/
private void selectMarkupRadioButton(final String contentCode) {
// Clear the current selection
this.markupContentTypeBox.getChildren()
.stream()
.filter(child -> child instanceof RadioButton)
.map(child -> (RadioButton) child)
.forEach(button -> button.setSelected(false));
Optional<RadioButton> radioButton = this.markupContentTypeBox.getChildren()
.stream()
.filter(child -> child instanceof RadioButton)
.map(child -> (RadioButton) child)
.filter(button -> ((IMarkup) button.getUserData()).getCode().equals(contentCode))
.findFirst();
if (radioButton.isPresent()) radioButton.get().setSelected(true);
}
/**
* Refresh the part of the view that allows to choose a markup syntax to reflect the currently installed plugin.
*/
public void refreshMarkupSyntax() {
// Clear already present markups
final Iterator<Node> it = this.markupContentTypeBox.getChildren().iterator();
Node child;
while (it.hasNext()) {
child = it.next();
if (child instanceof RadioButton) it.remove();
}
// Creating RadioButtons for each markup bundle installed
MarkupManager.getInstalledMarkupSyntax().stream()
.sorted((markup1, markup2) -> markup1.getName().compareToIgnoreCase(markup2.getName()))
.forEach(markup -> createRadioButtonForMakup(markup));
}
/**
* Refresh the UI in order to display all content extensions that are installed on the system.
*/
public void refreshContentExtensions() {
final Iterator<Node> iterator = this.contentExtensionToolBar.getItems().iterator();
Node child;
while (iterator.hasNext()) {
child = iterator.next();
if (child instanceof Button && child.getUserData() instanceof IContentExtension) iterator.remove();
}
// Creating Buttons for each extension bundle installed
OSGiManager.getInstance().getInstalledServices(IContentExtension.class)
.stream()
.sorted((extension1, extension2) -> extension1.getCode().compareTo(extension2.getCode()))
.forEach(extension -> createButtonForContentExtension(extension));
}
/**
* Reload the browser displaying the presentation.
*
* @return A {@link CompletableFuture} which will be completed when the browser is no more loading it's content.
*/
public CompletableFuture<Boolean> reloadPresentationBrowser() {
return this.browser.reload();
}
/**
* Loads the presentation file in the browser displaying it. If the presentation fil is {@code null} or does not
* exists, nothing if done.
*/
public void loadPresentationInBrowser() {
if (this.presentationEngine.getConfiguration().getPresentationFile() != null
&& this.presentationEngine.getConfiguration().getPresentationFile().exists()) {
this.browser.loadPresentation(this.presentationEngine);
}
}
/**
* Defines the presentation for the given view and load it in the browser.
*
* @param presentation The presentation associated to the view.
* @throws NullPointerException If {@code presentation} is {@code null}.
*/
public void definePresentation(final PresentationEngine presentation) {
if (presentation == null) throw new NullPointerException("The presentation can not be null");
this.presentationEngine = presentation;
this.loadPresentationInBrowser();
try {
final JavaBeanObjectProperty<File> archiveFile = new JavaBeanObjectPropertyBuilder<>()
.bean(this.presentationEngine)
.getter("getArchive")
.setter("setArchive")
.name("archiveFile")
.build();
((SimpleStringProperty) this.presentationName).bind(new FilenameBinding(archiveFile));
final JavaBeanObjectProperty<Boolean> presentationModifiedSinceLatestSave = new JavaBeanObjectPropertyBuilder<>()
.bean(this.presentationEngine)
.getter("isModifiedSinceLatestSave")
.setter("setModifiedSinceLatestSave")
.name("modifiedSinceLatestSave")
.build();
((SimpleBooleanProperty) this.presentationModified).bind(presentationModifiedSinceLatestSave);
} catch (NoSuchMethodException e) {
LOGGER.log(Level.SEVERE, "Can not create the property for the name of the presentation", e);
}
}
/**
* Get the presentation name. This will typically be the name of the {@link PresentationEngine#getArchive()}
* object, or "Untitled" if it doesn't exist.
*
* @return The name of this presentation.
*/
public ReadOnlyStringProperty getPresentationName() {
return this.presentationName;
}
/**
* Indicates if the presentation has been modified since the latest time it has been saved.
*
* @return The property indicating if the presentation has been modified since the latest save.
*/
public ReadOnlyBooleanProperty presentationModifiedProperty() {
return presentationModified;
}
/**
* Get the slide number of the slide currently displayed.
*
* @return The slide number of the current displayed slide or {@code null} if no slide is displayed.
*/
public String getCurrentSlideNumber() {
String slideNumber = null;
final String slideId = this.getCurrentSlideId();
if (slideId != null && !slideId.isEmpty()) {
slideNumber = slideId.substring(this.presentationEngine.getTemplateConfiguration().getSlideIdPrefix().length());
}
return slideNumber;
}
/**
* Get the ID of the slide currently displayed.
*
* @return The ID of the slide currently displayed or {@code null} if no slide is displayed.
*/
public String getCurrentSlideId() {
return this.browser.getCurrentSlideId();
}
/**
* Go to a specific slide ID. If the given ID is {@code null} or empty, nothing will be performed.
*
* @param slideId The ID of the slide to go to.
*/
public void goToSlide(final String slideId) {
if (slideId != null && !slideId.isEmpty()) {
this.browser.slide(slideId);
}
}
/**
* Print the current presentation.
*/
public void printPresentation() {
this.browser.print();
}
/**
* Set the presentation displayed in this view as the one currently displayed.
*/
public void setAsCurrentPresentation() {
Presentations.setCurrentDisplayedPresentation(this.presentationEngine);
}
/**
* Get the presentation associated to this view.
*
* @return The presentation associated to this view.
*/
public PresentationEngine getPresentation() {
return this.presentationEngine;
}
@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
// Make this controller available to JavaScript
this.browser.setPresentation(this.presentationEngine);
this.browser.setBackend(this);
this.refreshMarkupSyntax();
// Creating buttons for each content extension bundle installed
OSGiManager.getInstance().getInstalledServices(IContentExtension.class)
.stream()
.sorted((contentExtension1, contentExtension2) -> contentExtension1.getCode().compareTo(contentExtension2.getCode()))
.forEach(contentExtension -> createButtonForContentExtension(contentExtension));
// Change the mode for the content editor as the selection for markup language changes
this.markupContentType.selectedToggleProperty().addListener((value, oldToggle, newToggle) -> {
if (newToggle == null) {
this.contentEditor.setMode(null);
} else {
this.contentEditor.setMode(((IMarkup) newToggle.getUserData()).getAceMode());
}
});
this.defineContent.disableProperty().bind(this.slideNumber.textProperty().isEmpty()
.or(this.fieldName.textProperty().isEmpty())
.or(this.markupContentType.selectedToggleProperty().isNull()));
// Add a shortcut to the content editor for defining the content using META + Enter
this.contentEditor.registerEvent(KeyEvent.KEY_PRESSED, event -> {
if (event.isShortcutDown() && KeyCode.ENTER.equals(event.getCode())) {
event.consume();
try {
this.updateSlide();
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Can not define content", e);
}
}
});
}
}