package org.codefx.libfx.control.webview; import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.stream.Stream; import javafx.application.Platform; import javafx.beans.value.ObservableValue; import javafx.concurrent.Worker.State; import javafx.scene.web.WebView; import javax.swing.event.HyperlinkEvent; import javax.swing.event.HyperlinkEvent.EventType; import org.codefx.libfx.concurrent.when.ExecuteAlwaysWhen; import org.codefx.libfx.concurrent.when.ExecuteWhen; import org.codefx.libfx.dom.DomEventConverter; import org.codefx.libfx.dom.DomEventType; import org.w3c.dom.NodeList; import org.w3c.dom.events.Event; import org.w3c.dom.events.EventListener; import org.w3c.dom.events.EventTarget; /** * A default implementation of {@link WebViewHyperlinkListenerHandle} which acts on the {@link WebView} and * {@link WebViewHyperlinkListener} specified during construction. */ class DefaultWebViewHyperlinkListenerHandle implements WebViewHyperlinkListenerHandle { // Inspired by : // - http://stackoverflow.com/q/17555937 - // - http://blogs.kiyut.com/tonny/2013/07/30/javafx-webview-addhyperlinklistener/ /* * Many type names do not allow to easily recognize whether they come from the DOM- or the JavaFX-packages. To make * it easier, all instances of org.w3c.dom classes carry a 'dom'-prefix. */ // #begin FIELDS /** * The {@link WebView} to which the {@link #domEventListener} will be attached. */ private final WebView webView; /** * The {@link WebViewHyperlinkListener} which will be called by {@link #domEventListener} when an event occurs. */ private final WebViewHyperlinkListener eventListener; /** * The filter for events by their {@link EventType}. If the filter is empty, all events will be processed. Otherwise * only events of the present type will be processed. */ private final Optional<EventType> eventTypeFilter; /** * The DOM-{@link EventListener} which will be attached to the {@link #webView}. */ private final EventListener domEventListener; /** * Converts the observed DOM {@link Event}s to {@link HyperlinkEvent}s. */ private final DomEventConverter eventConverter; /** * Executes {@link #attachListenerInApplicationThread()} each time the web view's load worker changes its state to * {@link State#SUCCEEDED SUCCEEDED}. * <p> * The executer is only present while the listener is {@link #attached}. */ private Optional<ExecuteAlwaysWhen<State>> attachWhenLoadSucceeds; /** * Indicates whether the listener is currently attached. */ private boolean attached; // #end FIELDS /** * Creates a new listener handle for the specified arguments. The listener is not attached to the web view. * * @param webView * the {@link WebView} to which the {@code eventListener} will be attached * @param eventListener * the {@link WebViewHyperlinkListener} which will be attached to the {@code webView} * @param eventTypeFilter * the filter for events by their {@link EventType} * @param eventConverter * the converter for DOM {@link Event}s */ public DefaultWebViewHyperlinkListenerHandle( WebView webView, WebViewHyperlinkListener eventListener, Optional<EventType> eventTypeFilter, DomEventConverter eventConverter) { this.webView = webView; this.eventListener = eventListener; this.eventTypeFilter = eventTypeFilter; this.eventConverter = eventConverter; domEventListener = this::callHyperlinkListenerWithEvent; } // #begin ATTACH @Override public void attach() { if (attached) return; attached = true; if (Platform.isFxApplicationThread()) attachInApplicationThreadEachTimeLoadSucceeds(); else Platform.runLater(() -> attachInApplicationThreadEachTimeLoadSucceeds()); } /** * Attaches the {@link #domEventListener} to the {@link #webView} every time the {@code webView} successfully loaded * a page. * <p> * Must be called in JavaFX application thread. */ private void attachInApplicationThreadEachTimeLoadSucceeds() { ObservableValue<State> webWorkerState = webView.getEngine().getLoadWorker().stateProperty(); ExecuteAlwaysWhen<State> attachWhenLoadSucceeds = ExecuteWhen .on(webWorkerState) .when(state -> state == State.SUCCEEDED) .thenAlways(state -> attachListenerInApplicationThread()); this.attachWhenLoadSucceeds = Optional.of(attachWhenLoadSucceeds); attachWhenLoadSucceeds.executeWhen(); } /** * Attaches the {@link #domEventListener} to the {@link #webView}. * <p> * Must be called in JavaFX application thread. */ private void attachListenerInApplicationThread() { BiConsumer<EventTarget, String> addListener = (eventTarget, eventType) -> eventTarget.addEventListener(eventType, domEventListener, false); onEachLinkForEachManagedEventType(addListener); } // #end ATTACH // #begin DETACH @Override public void detach() { if (!attached) return; attached = false; if (Platform.isFxApplicationThread()) detachInApplicationThread(); else Platform.runLater(() -> detachInApplicationThread()); } /** * Detaches the {@link #domEventListener} from the {@link #webView} and cancels and resets * {@link #attachWhenLoadSucceeds}. * <p> * Must be called in JavaFX application thread. */ private void detachInApplicationThread() { attachWhenLoadSucceeds.ifPresent(attachWhen -> attachWhen.cancel()); attachWhenLoadSucceeds = Optional.empty(); // it suffices to remove the listener if the worker state is on SUCCEEDED; // because when the view is currently loading, the canceled 'attachWhen' will not re-add the listener State webWorkerState = webView.getEngine().getLoadWorker().getState(); if (webWorkerState == State.SUCCEEDED) { BiConsumer<EventTarget, String> removeListener = (eventTarget, eventType) -> eventTarget.removeEventListener(eventType, domEventListener, false); onEachLinkForEachManagedEventType(removeListener); } } // #end DETACH // #begin COMMON MANAGEMENT METHODS /** * Executes the specified function on each link in the {@link #webView}'s current document for each * {@link DomEventType} for which {@link #manageListenerForEventType(DomEventType)} returns true. * <p> * Must be called in JavaFX application thread. * * @param manageListener * a {@link BiConsumer} which acts on a link and a DOM event type */ private void onEachLinkForEachManagedEventType(BiConsumer<EventTarget, String> manageListener) { NodeList domNodeList = webView.getEngine().getDocument().getElementsByTagName("a"); for (int i = 0; i < domNodeList.getLength(); i++) { EventTarget domTarget = (EventTarget) domNodeList.item(i); onLinkForEachManagedEventType(domTarget, manageListener); } } /** * Executes the specified function on the specified link for each {@link DomEventType} for which * {@link #manageListenerForEventType(DomEventType)} returns true. * <p> * Must be called in JavaFX application thread. * * @param link * The {@link EventTarget} with which {@code manageListener} will be called * @param manageListener * a {@link BiConsumer} which acts on a link and a DOM event type */ private void onLinkForEachManagedEventType(EventTarget link, BiConsumer<EventTarget, String> manageListener) { Consumer<DomEventType> manageListenerForType = domEventType -> manageListener.accept(link, domEventType.getDomName()); Stream.of(DomEventType.values()) .filter(this::manageListenerForEventType) .forEach(manageListenerForType); } /** * Indicates whether a listener must be added for the specified DOM event type and the {@link #eventTypeFilter}. * * @param domEventType * the {@link DomEventType} for which a listener might be added * @return true if the DOM event type has a representation as an hyperlink event type and is not filtered out; false * otherwise */ private boolean manageListenerForEventType(DomEventType domEventType) { boolean domEventTypeHasRepresentation = domEventType.toHyperlinkEventType().isPresent(); if (!domEventTypeHasRepresentation) return false; boolean filterOn = eventTypeFilter.isPresent(); if (!filterOn) return true; return domEventType.toHyperlinkEventType().get() == eventTypeFilter.get(); } // #end COMMON MANAGEMENT METHODS // #begin PROCESS EVENT /** * Converts the specified {@code domEvent} into a {@link HyperlinkEvent} and calls the {@link #eventListener} with * it. * * @param domEvent * the DOM-{@link Event} */ private void callHyperlinkListenerWithEvent(Event domEvent) { boolean canNotConvertEvent = !eventConverter.canConvertToHyperlinkEvent(domEvent); if (canNotConvertEvent) return; HyperlinkEvent event = eventConverter.convertToHyperlinkEvent(domEvent, webView); boolean cancel = eventListener.hyperlinkUpdate(event); cancel(domEvent, cancel); } /** * Cancels the specified event if it is cancelable and cancellation is indicated by the specified flag. * * @param domEvent * the DOM-{@link Event} to be canceled * @param cancel * indicates whether the event should be canceled */ private static void cancel(Event domEvent, boolean cancel) { if (domEvent.getCancelable() && cancel) domEvent.preventDefault(); } // #end PROCESS EVENT }