package org.intellij.plugins.markdown.ui.preview;
import com.intellij.CommonBundle;
import com.intellij.codeHighlighting.BackgroundEditorHighlighter;
import com.intellij.ide.structureView.StructureViewBuilder;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.event.DocumentEvent;
import com.intellij.openapi.editor.event.DocumentListener;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.fileEditor.FileEditor;
import com.intellij.openapi.fileEditor.FileEditorLocation;
import com.intellij.openapi.fileEditor.FileEditorState;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.NotNullLazyValue;
import com.intellij.openapi.util.UserDataHolderBase;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.Alarm;
import com.intellij.util.messages.MessageBusConnection;
import org.intellij.markdown.IElementType;
import org.intellij.markdown.ast.ASTNode;
import org.intellij.markdown.html.GeneratingProvider;
import org.intellij.markdown.html.HtmlGenerator;
import org.intellij.markdown.parser.LinkMap;
import org.intellij.markdown.parser.MarkdownParser;
import org.intellij.plugins.markdown.lang.parser.MarkdownParserManager;
import org.intellij.plugins.markdown.settings.MarkdownApplicationSettings;
import org.intellij.plugins.markdown.settings.MarkdownCssSettings;
import org.intellij.plugins.markdown.settings.MarkdownPreviewSettings;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.owasp.html.Sanitizers;
import javax.swing.*;
import java.awt.*;
import java.beans.PropertyChangeListener;
import java.io.File;
import java.net.URI;
import java.util.Map;
public class MarkdownPreviewFileEditor extends UserDataHolderBase implements FileEditor {
private final static long PARSING_CALL_TIMEOUT_MS = 50L;
private final static long RENDERING_DELAY_MS = 20L;
final static NotNullLazyValue<PolicyFactory> SANITIZER_VALUE = new NotNullLazyValue<PolicyFactory>() {
@NotNull
@Override
protected PolicyFactory compute() {
return Sanitizers.BLOCKS
.and(Sanitizers.FORMATTING)
.and(new HtmlPolicyBuilder()
.allowUrlProtocols("file", "http", "https").allowElements("img")
.allowAttributes("alt", "src", "title").onElements("img")
.allowAttributes("border", "height", "width").onElements("img")
.toFactory())
.and(new HtmlPolicyBuilder()
.allowUrlProtocols("file", "http", "https", "mailto").allowElements("a")
.allowAttributes("href", "title").onElements("a")
.toFactory())
.and(Sanitizers.TABLES)
.and(new HtmlPolicyBuilder()
.allowElements("body", "pre")
.allowAttributes(HtmlGenerator.Companion.getSRC_ATTRIBUTE_NAME()).globally().toFactory())
.and(new HtmlPolicyBuilder()
.allowElements("code", "tr")
.allowAttributes("class").onElements("code", "tr")
.toFactory());
}
};
@NotNull
private final JPanel myHtmlPanelWrapper;
@NotNull
private MarkdownHtmlPanel myPanel;
@Nullable
private MarkdownHtmlPanelProvider.ProviderInfo myLastPanelProviderInfo = null;
@NotNull
private final VirtualFile myFile;
@Nullable
private final Document myDocument;
@NotNull
private final Alarm myPooledAlarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, this);
@NotNull
private final Alarm mySwingAlarm = new Alarm(Alarm.ThreadToUse.SWING_THREAD, this);
private final Object REQUESTS_LOCK = new Object();
@Nullable
private Runnable myLastScrollRequest = null;
@Nullable
private Runnable myLastHtmlOrRefreshRequest = null;
private volatile int myLastScrollOffset;
@NotNull
private String myLastRenderedHtml = "";
public MarkdownPreviewFileEditor(@NotNull VirtualFile file) {
myFile = file;
myDocument = FileDocumentManager.getInstance().getDocument(myFile);
if (myDocument != null) {
myDocument.addDocumentListener(new DocumentListener() {
@Override
public void beforeDocumentChange(DocumentEvent e) {
myPooledAlarm.cancelAllRequests();
}
@Override
public void documentChanged(final DocumentEvent e) {
myPooledAlarm.addRequest(() -> {
//myLastScrollOffset = e.getOffset();
updateHtml(true);
}, PARSING_CALL_TIMEOUT_MS);
}
}, this);
}
myHtmlPanelWrapper = new JPanel(new BorderLayout());
final MarkdownApplicationSettings settings = MarkdownApplicationSettings.getInstance();
myPanel = detachOldPanelAndCreateAndAttachNewOne(myHtmlPanelWrapper, null, retrievePanelProvider(settings));
updatePanelCssSettings(myPanel, settings.getMarkdownCssSettings());
MessageBusConnection settingsConnection = ApplicationManager.getApplication().getMessageBus().connect(this);
MarkdownApplicationSettings.SettingsChangedListener settingsChangedListener = new MyUpdatePanelOnSettingsChangedListener();
settingsConnection.subscribe(MarkdownApplicationSettings.SettingsChangedListener.TOPIC, settingsChangedListener);
}
public void scrollToSrcOffset(final int offset) {
// Do not scroll if html update request is online
// This will restrain preview from glitches on editing
if (!myPooledAlarm.isEmpty()) {
myLastScrollOffset = offset;
return;
}
synchronized (REQUESTS_LOCK) {
if (myLastScrollRequest != null) {
mySwingAlarm.cancelRequest(myLastScrollRequest);
}
myLastScrollRequest = () -> {
myLastScrollOffset = offset;
myPanel.scrollToMarkdownSrcOffset(myLastScrollOffset);
synchronized (REQUESTS_LOCK) {
myLastScrollRequest = null;
}
};
mySwingAlarm.addRequest(myLastScrollRequest, RENDERING_DELAY_MS, ModalityState.stateForComponent(getComponent()));
}
}
@NotNull
@Override
public JComponent getComponent() {
return myHtmlPanelWrapper;
}
@Nullable
@Override
public JComponent getPreferredFocusedComponent() {
return myPanel.getComponent();
}
@NotNull
@Override
public String getName() {
return "Markdown HTML Preview";
}
@Override
public void setState(@NotNull FileEditorState state) {
}
@Override
public boolean isModified() {
return false;
}
@Override
public boolean isValid() {
return true;
}
@Override
public void selectNotify() {
myPooledAlarm.cancelAllRequests();
myPooledAlarm.addRequest(() -> updateHtml(true), 0);
}
@Nullable("Null means leave current panel")
private MarkdownHtmlPanelProvider retrievePanelProvider(@NotNull MarkdownApplicationSettings settings) {
final MarkdownHtmlPanelProvider.ProviderInfo providerInfo = settings.getMarkdownPreviewSettings().getHtmlPanelProviderInfo();
if (providerInfo.equals(myLastPanelProviderInfo)) {
return null;
}
MarkdownHtmlPanelProvider provider = MarkdownHtmlPanelProvider.createFromInfo(providerInfo);
if (provider.isAvailable() != MarkdownHtmlPanelProvider.AvailabilityInfo.AVAILABLE) {
settings.setMarkdownPreviewSettings(new MarkdownPreviewSettings(settings.getMarkdownPreviewSettings().getSplitEditorLayout(),
MarkdownPreviewSettings.DEFAULT.getHtmlPanelProviderInfo(),
settings.getMarkdownPreviewSettings().isUseGrayscaleRendering()));
Messages.showMessageDialog(
myHtmlPanelWrapper,
"Tried to use preview panel provider (" + providerInfo.getName() + "), but it is unavailable. Reverting to default.",
CommonBundle.getErrorTitle(),
Messages.getErrorIcon()
);
provider = MarkdownHtmlPanelProvider.getProviders()[0];
}
myLastPanelProviderInfo = settings.getMarkdownPreviewSettings().getHtmlPanelProviderInfo();
return provider;
}
/**
* Is always run from pooled thread
*/
private void updateHtml(final boolean preserveScrollOffset) {
if (!myFile.isValid() || myDocument == null || Disposer.isDisposed(this)) {
return;
}
final String html = generateMarkdownHtml(myFile, myDocument.getText());
// EA-75860: The lines to the top may be processed slowly; Since we're in pooled thread, we can be disposed already.
if (!myFile.isValid() || Disposer.isDisposed(this)) {
return;
}
synchronized (REQUESTS_LOCK) {
if (myLastHtmlOrRefreshRequest != null) {
mySwingAlarm.cancelRequest(myLastHtmlOrRefreshRequest);
}
myLastHtmlOrRefreshRequest = () -> {
final String currentHtml = "<html><head></head>" + SANITIZER_VALUE.getValue().sanitize(html) + "</html>";
if (!currentHtml.equals(myLastRenderedHtml)) {
myLastRenderedHtml = currentHtml;
myPanel.setHtml(myLastRenderedHtml);
if (preserveScrollOffset) {
myPanel.scrollToMarkdownSrcOffset(myLastScrollOffset);
}
}
myPanel.render();
synchronized (REQUESTS_LOCK) {
myLastHtmlOrRefreshRequest = null;
}
};
mySwingAlarm.addRequest(myLastHtmlOrRefreshRequest, RENDERING_DELAY_MS, ModalityState.stateForComponent(getComponent()));
}
}
@Override
public void deselectNotify() {
}
@Override
public void addPropertyChangeListener(@NotNull PropertyChangeListener listener) {
}
@Override
public void removePropertyChangeListener(@NotNull PropertyChangeListener listener) {
}
@Nullable
@Override
public BackgroundEditorHighlighter getBackgroundHighlighter() {
return null;
}
@Nullable
@Override
public FileEditorLocation getCurrentLocation() {
return null;
}
@Nullable
@Override
public StructureViewBuilder getStructureViewBuilder() {
return null;
}
@Override
public void dispose() {
Disposer.dispose(myPanel);
}
@NotNull
private static String generateMarkdownHtml(@NotNull VirtualFile file, @NotNull String text) {
final VirtualFile parent = file.getParent();
final URI baseUri = parent != null ? new File(parent.getPath()).toURI() : null;
final ASTNode parsedTree = new MarkdownParser(MarkdownParserManager.FLAVOUR).buildMarkdownTreeFromString(text);
final Map<IElementType, GeneratingProvider> htmlGeneratingProviders =
MarkdownParserManager.FLAVOUR.createHtmlGeneratingProviders(LinkMap.Builder.buildLinkMap(parsedTree, text), baseUri);
return new HtmlGenerator(text, parsedTree, htmlGeneratingProviders, true).generateHtml();
}
@Contract("_, null, null -> fail")
@NotNull
private static MarkdownHtmlPanel detachOldPanelAndCreateAndAttachNewOne(@NotNull JPanel panelWrapper,
@Nullable MarkdownHtmlPanel oldPanel,
@Nullable MarkdownHtmlPanelProvider newPanelProvider) {
ApplicationManager.getApplication().assertIsDispatchThread();
if (oldPanel == null && newPanelProvider == null) {
throw new IllegalArgumentException("Either create new one or leave the old");
}
if (newPanelProvider == null) {
return oldPanel;
}
if (oldPanel != null) {
panelWrapper.remove(oldPanel.getComponent());
Disposer.dispose(oldPanel);
}
final MarkdownHtmlPanel newPanel = newPanelProvider.createHtmlPanel();
panelWrapper.add(newPanel.getComponent(), BorderLayout.CENTER);
panelWrapper.repaint();
return newPanel;
}
private static void updatePanelCssSettings(@NotNull MarkdownHtmlPanel panel, @NotNull final MarkdownCssSettings cssSettings) {
ApplicationManager.getApplication().assertIsDispatchThread();
final String inlineCss = cssSettings.isTextEnabled() ? cssSettings.getStylesheetText() : null;
if (cssSettings.isUriEnabled()) {
panel.setCSS(inlineCss, cssSettings.getStylesheetUri());
}
else {
panel.setCSS(inlineCss);
}
panel.render();
}
private class MyUpdatePanelOnSettingsChangedListener implements MarkdownApplicationSettings.SettingsChangedListener {
@Override
public void onSettingsChange(@NotNull MarkdownApplicationSettings settings) {
final MarkdownHtmlPanelProvider newPanelProvider = retrievePanelProvider(settings);
mySwingAlarm.addRequest(() -> {
myPanel = detachOldPanelAndCreateAndAttachNewOne(myHtmlPanelWrapper, myPanel, newPanelProvider);
myPanel.setHtml(myLastRenderedHtml);
updatePanelCssSettings(myPanel, settings.getMarkdownCssSettings());
}, 0, ModalityState.stateForComponent(getComponent()));
}
}
}