package org.intellij.plugins.markdown.ui.preview.javafx; import com.intellij.ide.IdeEventQueue; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.util.NotNullLazyValue; import com.intellij.ui.JBColor; import com.intellij.util.ArrayUtil; import com.intellij.util.containers.ContainerUtil; import com.intellij.util.messages.MessageBusConnection; import com.intellij.util.ui.JBUI; import com.sun.javafx.application.PlatformImpl; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.concurrent.Worker.State; import javafx.embed.swing.JFXPanel; import javafx.scene.Scene; import javafx.scene.text.FontSmoothingType; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; import netscape.javascript.JSObject; import org.intellij.markdown.html.HtmlGenerator; import org.intellij.plugins.markdown.settings.MarkdownApplicationSettings; import org.intellij.plugins.markdown.ui.preview.MarkdownHtmlPanel; import org.intellij.plugins.markdown.ui.preview.PreviewStaticServer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.swing.*; import java.awt.*; import java.util.ArrayList; import java.util.List; public class JavaFxHtmlPanel extends MarkdownHtmlPanel { private static final NotNullLazyValue<String> MY_SCRIPTING_LINES = new NotNullLazyValue<String>() { @NotNull @Override protected String compute() { return SCRIPTS.stream() .map(s -> "<script src=\"" + PreviewStaticServer.getScriptUrl(s) + "\"></script>") .reduce((s, s2) -> s + "\n" + s2) .orElseGet(String::new); } }; @NotNull private final JPanel myPanelWrapper; @NotNull private final List<Runnable> myInitActions = new ArrayList<>(); @Nullable private JFXPanel myPanel; @Nullable private WebView myWebView; @NotNull private String[] myCssUris = ArrayUtil.EMPTY_STRING_ARRAY; @NotNull private String myCSP = ""; @NotNull private String myLastRawHtml = ""; @NotNull private final ScrollPreservingListener myScrollPreservingListener = new ScrollPreservingListener(); @NotNull private final BridgeSettingListener myBridgeSettingListener = new BridgeSettingListener(); public JavaFxHtmlPanel() { //System.setProperty("prism.lcdtext", "false"); //System.setProperty("prism.text", "t2k"); myPanelWrapper = new JPanel(new BorderLayout()); myPanelWrapper.setBackground(JBColor.background()); ApplicationManager.getApplication().invokeLater(() -> runFX(() -> PlatformImpl.startup(() -> { myWebView = new WebView(); updateFontSmoothingType(myWebView, MarkdownApplicationSettings.getInstance().getMarkdownPreviewSettings().isUseGrayscaleRendering()); myWebView.setContextMenuEnabled(false); myWebView.setZoom(JBUI.scale(1.f)); final WebEngine engine = myWebView.getEngine(); engine.getLoadWorker().stateProperty().addListener(myBridgeSettingListener); engine.getLoadWorker().stateProperty().addListener(myScrollPreservingListener); final Scene scene = new Scene(myWebView); ApplicationManager.getApplication().invokeLater(() -> runFX(() -> { myPanel = new JFXPanelWrapper(); Platform.runLater(() -> myPanel.setScene(scene)); setHtml(""); for (Runnable action : myInitActions) { Platform.runLater(action); } myInitActions.clear(); myPanelWrapper.add(myPanel, BorderLayout.CENTER); myPanelWrapper.repaint(); })); }))); subscribeForGrayscaleSetting(); } private static void runFX(@NotNull Runnable r) { IdeEventQueue.unsafeNonblockingExecute(r); } private void runInPlatformWhenAvailable(@NotNull Runnable runnable) { ApplicationManager.getApplication().assertIsDispatchThread(); if (myPanel == null) { myInitActions.add(runnable); } else { Platform.runLater(runnable); } } private void subscribeForGrayscaleSetting() { MessageBusConnection settingsConnection = ApplicationManager.getApplication().getMessageBus().connect(this); MarkdownApplicationSettings.SettingsChangedListener settingsChangedListener = new MarkdownApplicationSettings.SettingsChangedListener() { @Override public void onSettingsChange(@NotNull final MarkdownApplicationSettings settings) { runInPlatformWhenAvailable(() -> { if (myWebView != null) { updateFontSmoothingType(myWebView, settings.getMarkdownPreviewSettings().isUseGrayscaleRendering()); } }); } }; settingsConnection.subscribe(MarkdownApplicationSettings.SettingsChangedListener.TOPIC, settingsChangedListener); } private static void updateFontSmoothingType(@NotNull WebView view, boolean isGrayscale) { final FontSmoothingType typeToSet; if (isGrayscale) { typeToSet = FontSmoothingType.GRAY; } else { typeToSet = FontSmoothingType.LCD; } view.fontSmoothingTypeProperty().setValue(typeToSet); } @NotNull @Override public JComponent getComponent() { return myPanelWrapper; } @Override public void setHtml(@NotNull String html) { myLastRawHtml = html; final String htmlToRender = prepareHtml(html); runInPlatformWhenAvailable(() -> getWebViewGuaranteed().getEngine().loadContent(htmlToRender)); } @NotNull private String prepareHtml(@NotNull String html) { return ImageRefreshFix.setStamps(html .replace("<head>", "<head>" + "<meta http-equiv=\"Content-Security-Policy\" content=\"" + myCSP + "\"/>" + getCssLines(null, myCssUris) + "\n" + getScriptingLines())); } @Override public void setCSS(@Nullable String inlineCss, @NotNull String... fileUris) { PreviewStaticServer.getInstance().setInlineStyle(inlineCss); myCssUris = inlineCss == null ? fileUris : ArrayUtil.mergeArrays(fileUris, PreviewStaticServer.getStyleUrl(PreviewStaticServer.INLINE_CSS_FILENAME)); myCSP = PreviewStaticServer.createCSP(ContainerUtil.map(SCRIPTS, s -> PreviewStaticServer.getScriptUrl(s)), ContainerUtil.concat( ContainerUtil.map(STYLES, s -> PreviewStaticServer.getStyleUrl(s)), ContainerUtil.filter(fileUris, s -> s.startsWith("http://") || s.startsWith("https://")) )); setHtml(myLastRawHtml); } @Override public void render() { runInPlatformWhenAvailable(() -> { getWebViewGuaranteed().getEngine().reload(); ApplicationManager.getApplication().invokeLater(myPanelWrapper::repaint); }); } @Override public void scrollToMarkdownSrcOffset(final int offset) { runInPlatformWhenAvailable(() -> { getWebViewGuaranteed().getEngine().executeScript( "if ('__IntelliJTools' in window) " + "__IntelliJTools.scrollToOffset(" + offset + ", '" + HtmlGenerator.Companion.getSRC_ATTRIBUTE_NAME() + "');" ); final Object result = getWebViewGuaranteed().getEngine().executeScript( "document.documentElement.scrollTop || document.body.scrollTop"); if (result instanceof Number) { myScrollPreservingListener.myScrollY = ((Number)result).intValue(); } }); } @Override public void dispose() { runInPlatformWhenAvailable(() -> { getWebViewGuaranteed().getEngine().getLoadWorker().stateProperty().removeListener(myScrollPreservingListener); getWebViewGuaranteed().getEngine().getLoadWorker().stateProperty().removeListener(myBridgeSettingListener); }); } @NotNull private WebView getWebViewGuaranteed () { if (myWebView == null) { throw new IllegalStateException("WebView should be initialized by now. Check the caller thread"); } return myWebView; } @NotNull private static String getScriptingLines() { return MY_SCRIPTING_LINES.getValue(); } @SuppressWarnings("unused") public static class JavaPanelBridge { static final JavaPanelBridge INSTANCE = new JavaPanelBridge(); public void openInExternalBrowser(@NotNull String link) { SafeOpener.openLink(link); } public void log(@Nullable String text) { Logger.getInstance(JavaPanelBridge.class).warn(text); } } private class BridgeSettingListener implements ChangeListener<State> { @Override public void changed(ObservableValue<? extends State> observable, State oldValue, State newValue) { JSObject win = (JSObject)getWebViewGuaranteed().getEngine().executeScript("window"); win.setMember("JavaPanelBridge", JavaPanelBridge.INSTANCE); } } private class ScrollPreservingListener implements ChangeListener<State> { volatile int myScrollY = 0; @Override public void changed(ObservableValue<? extends State> observable, State oldValue, State newValue) { if (newValue == State.RUNNING) { final Object result = getWebViewGuaranteed().getEngine().executeScript("document.documentElement.scrollTop || document.body.scrollTop"); if (result instanceof Number) { myScrollY = ((Number)result).intValue(); } } else if (newValue == State.SUCCEEDED) { getWebViewGuaranteed().getEngine() .executeScript("document.documentElement.scrollTop = ({} || document.body).scrollTop = " + myScrollY); } } } }