package org.asciidoc.intellij.editor.javafx;
import com.intellij.ide.BrowserUtil;
import com.intellij.notification.Notification;
import com.intellij.notification.NotificationType;
import com.intellij.notification.Notifications;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.editor.CaretState;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.LogicalPosition;
import com.intellij.openapi.editor.ScrollType;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.util.NotNullLazyValue;
import com.intellij.ui.JBColor;
import com.intellij.util.ui.UIUtil;
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.apache.commons.io.IOUtils;
import org.asciidoc.intellij.editor.AsciiDocHtmlPanel;
import org.asciidoc.intellij.editor.AsciiDocPreviewEditor;
import org.asciidoc.intellij.settings.AsciiDocApplicationSettings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.MatchResult;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class JavaFxHtmlPanel extends AsciiDocHtmlPanel {
private static final NotNullLazyValue<String> MY_SCRIPTING_LINES = new NotNullLazyValue<String>() {
@NotNull
@Override
protected String compute() {
final Class<JavaFxHtmlPanel> clazz = JavaFxHtmlPanel.class;
//noinspection StringBufferReplaceableByString
return new StringBuilder()
.append("<script src=\"").append(clazz.getResource("scrollToElement.js")).append("\"></script>\n")
.append("<script src=\"").append(clazz.getResource("processLinks.js")).append("\"></script>\n")
.append("<script src=\"").append(clazz.getResource("pickSourceLine.js")).append("\"></script>\n")
.toString();
}
};
@NotNull
private final JPanel myPanelWrapper;
@NotNull
private final List<Runnable> myInitActions = new ArrayList<Runnable>();
@Nullable
private volatile JFXPanel myPanel;
@Nullable
private WebView myWebView;
@Nullable
private String myInlineCss;
@Nullable
private String myInlineCssDarcula;
@NotNull
private final ScrollPreservingListener myScrollPreservingListener = new ScrollPreservingListener();
@NotNull
private final BridgeSettingListener myBridgeSettingListener = new BridgeSettingListener();
@NotNull
private String base;
private int lineCount;
private final Path imagesPath;
public JavaFxHtmlPanel(Document document, Path imagesPath) {
//System.setProperty("prism.lcdtext", "false");
//System.setProperty("prism.text", "t2k");
this.imagesPath = imagesPath;
myPanelWrapper = new JPanel(new BorderLayout());
myPanelWrapper.setBackground(JBColor.background());
lineCount = document.getLineCount();
base = FileDocumentManager.getInstance().getFile(document).getParent().getUrl().replaceAll("^file://", "")
.replaceAll(":", "%3A");
try {
myInlineCss = IOUtils.toString(JavaFxHtmlPanel.class.getResourceAsStream("default.css"));
myInlineCssDarcula = myInlineCss + IOUtils.toString(JavaFxHtmlPanel.class.getResourceAsStream("darcula.css"));
myInlineCssDarcula += IOUtils.toString(JavaFxHtmlPanel.class.getResourceAsStream("coderay-darcula.css"));
myInlineCss += IOUtils.toString(JavaFxHtmlPanel.class.getResourceAsStream("coderay.css"));
} catch (IOException e) {
String message = "Error rendering asciidoctor: " + e.getMessage();
Notification notification = AsciiDocPreviewEditor.NOTIFICATION_GROUP
.createNotification("Error rendering asciidoctor", message, NotificationType.ERROR, null);
// increase event log counter
notification.setImportant(true);
Notifications.Bus.notify(notification);
}
ApplicationManager.getApplication().invokeLater(new Runnable() {
@Override
public void run() {
PlatformImpl.startup(new Runnable() {
@Override
public void run() {
myWebView = new WebView();
updateFontSmoothingType(myWebView, false);
myWebView.setContextMenuEnabled(false);
myWebView.getEngine().loadContent(prepareHtml("<html><head></head><body>Initializing...</body>"));
final WebEngine engine = myWebView.getEngine();
engine.getLoadWorker().stateProperty().addListener(myBridgeSettingListener);
engine.getLoadWorker().stateProperty().addListener(myScrollPreservingListener);
final Scene scene = new Scene(myWebView);
ApplicationManager.getApplication().invokeLater(new Runnable() {
@Override
public void run() {
synchronized (myInitActions) {
myPanel = new JFXPanelWrapper();
Platform.runLater(() -> myPanel.setScene(scene));
for (Runnable action : myInitActions) {
Platform.runLater(action);
}
myInitActions.clear();
}
myPanelWrapper.add(myPanel, BorderLayout.CENTER);
myPanelWrapper.repaint();
}
});
}
});
}
});
}
private void runInPlatformWhenAvailable(@NotNull final Runnable runnable) {
synchronized (myInitActions) {
if (myPanel == null) {
myInitActions.add(runnable);
} else {
Platform.runLater(runnable);
}
}
}
private boolean isDarcula() {
final AsciiDocApplicationSettings settings = AsciiDocApplicationSettings.getInstance();
switch (settings.getAsciiDocPreviewSettings().getPreviewTheme()) {
case INTELLIJ:
return UIUtil.isUnderDarcula();
case ASCIIDOC:
return false;
case DARCULA:
return true;
default:
return false;
}
}
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) {
if (isDarcula()) {
// clear out coderay inline CSS colors as they are barely readable in darcula theme
html = html.replaceAll("<span style=\"color:#[a-zA-Z0-9]*;?", "<span style=\"");
html = html.replaceAll("<span style=\"background-color:#[a-zA-Z0-9]*;?", "<span style=\"");
}
html = "<html><head></head><body>" + html + "</body>";
final String htmlToRender = prepareHtml(html);
runInPlatformWhenAvailable(new Runnable() {
@Override
public void run() {
JavaFxHtmlPanel.this.getWebViewGuaranteed().getEngine().loadContent(htmlToRender);
}
});
}
private String findTempImageFile(String _fileName) {
Path file = imagesPath.resolve(_fileName);
if (Files.exists(file)) {
return file.toFile().toString();
}
return null;
}
private String prepareHtml(@NotNull String html) {
/* for each image we'll calculate a MD5 sum of its content. Once the content changes, MD5 and therefore the URL
* will change. The changed URL is necessary for the JavaFX web view to display the new content, as each URL
* will be loaded only once by the JavaFX web view. */
Pattern pattern = Pattern.compile("<img src=\"([^:\"]*)\"");
final Matcher matcher = pattern.matcher(html);
while (matcher.find()) {
final MatchResult matchResult = matcher.toMatchResult();
String file = matchResult.group(1);
String tmpFile = findTempImageFile(file);
String md5;
String replacement;
if (tmpFile != null) {
md5 = calculateMd5(tmpFile, null);
tmpFile = tmpFile.replaceAll("\\\\", "/");
tmpFile = tmpFile.replaceAll(":", "%3A");
replacement = "<img src=\"localfile://" + md5 + "/" + tmpFile + "\"";
} else {
md5 = calculateMd5(file, base);
replacement = "<img src=\"localfile://" + md5 + "/" + base + "/" + file + "\"";
}
html = html.substring(0, matchResult.start()) +
replacement + html.substring(matchResult.end());
matcher.reset(html);
}
/* Add CSS line and JavaScript for auto-scolling and clickable links */
return html
.replace("<head>", "<head>" + getCssLines(isDarcula() ? myInlineCssDarcula : myInlineCss))
.replace("</body>", getScriptingLines() + "</body>");
}
private String calculateMd5(String file, String base) {
String md5;
try {
MessageDigest md = MessageDigest.getInstance("MD5");
try(FileInputStream fis = new FileInputStream((base != null ? base.replaceAll("%3A", ":") + "/" : "") + file)) {
int nread;
byte[] dataBytes = new byte[1024];
while ((nread = fis.read(dataBytes)) != -1) {
md.update(dataBytes, 0, nread);
}
}
byte[] mdbytes = md.digest();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < mdbytes.length; i++) {
sb.append(Integer.toString((mdbytes[i] & 0xff) + 0x100, 16).substring(1));
}
md5 = sb.toString();
} catch (NoSuchAlgorithmException | IOException e) {
md5 = "none";
}
return md5;
}
@Override
public void render() {
runInPlatformWhenAvailable(new Runnable() {
@Override
public void run() {
JavaFxHtmlPanel.this.getWebViewGuaranteed().getEngine().reload();
ApplicationManager.getApplication().invokeLater(new Runnable() {
@Override
public void run() {
myPanelWrapper.repaint();
}
});
}
});
}
@Override
public void scrollToLine(final int line, final int lineCount) {
this.lineCount = lineCount;
runInPlatformWhenAvailable(new Runnable() {
@Override
public void run() {
JavaFxHtmlPanel.this.getWebViewGuaranteed().getEngine().executeScript(
"if ('__IntelliJTools' in window) " +
"__IntelliJTools.scrollToLine(" + line + ", " + lineCount + ");"
);
final Object result = JavaFxHtmlPanel.this.getWebViewGuaranteed().getEngine().executeScript(
"document.documentElement.scrollTop || document.body.scrollTop");
if (result instanceof Number) {
myScrollPreservingListener.myScrollY = ((Number) result).intValue();
}
}
});
}
@Override
public void dispose() {
runInPlatformWhenAvailable(new Runnable() {
@Override
public void run() {
JavaFxHtmlPanel.this.getWebViewGuaranteed().getEngine().load("about:blank");
JavaFxHtmlPanel.this.getWebViewGuaranteed().getEngine().getLoadWorker().stateProperty().removeListener(myScrollPreservingListener);
JavaFxHtmlPanel.this.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 class JavaPanelBridge {
public void openInExternalBrowser(@NotNull String link) {
if (!BrowserUtil.isAbsoluteURL(link)) {
try {
link = new URI("http", link, null).toURL().toString();
} catch (Exception ignore) {
ignore.printStackTrace();
}
}
BrowserUtil.browse(link);
}
public void scollEditorToLine(int sourceLine) {
ApplicationManager.getApplication().invokeLater(
() -> {
getEditor().getCaretModel().setCaretsAndSelections(
Arrays.asList(new CaretState(new LogicalPosition(sourceLine - 1, 0), null, null))
);
getEditor().getScrollingModel().scrollToCaret(ScrollType.CENTER_UP);
}
);
}
public void log(@Nullable String text) {
Logger.getInstance(JavaPanelBridge.class).warn(text);
}
}
/* keep bridge in class instance to avoid cleanup of bridge due to weak references in
JavaScript mappings starting from JDK 8 111
see: https://bugs.openjdk.java.net/browse/JDK-8170515
*/
private JavaPanelBridge bridge = new JavaPanelBridge();
private class BridgeSettingListener implements ChangeListener<State> {
@Override
public void changed(ObservableValue<? extends State> observable, State oldValue, State newValue) {
if (newValue == State.SUCCEEDED) {
JSObject win
= (JSObject) getWebViewGuaranteed().getEngine().executeScript("window");
win.setMember("JavaPanelBridge", bridge);
JavaFxHtmlPanel.this.getWebViewGuaranteed().getEngine().executeScript(
"if ('__IntelliJTools' in window) {" +
"__IntelliJTools.processLinks();" +
"__IntelliJTools.pickSourceLine(" + lineCount + ");" +
"}"
);
}
}
}
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);
}
}
}
}