package com.scrivenvar.preview;
import com.scrivenvar.Services;
import com.scrivenvar.service.events.Notifier;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.embed.swing.SwingNode;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import org.jsoup.Jsoup;
import org.jsoup.helper.W3CDom;
import org.jsoup.nodes.Document;
import org.xhtmlrenderer.event.DocumentListener;
import org.xhtmlrenderer.layout.SharedContext;
import org.xhtmlrenderer.render.Box;
import org.xhtmlrenderer.simple.XHTMLPanel;
import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler;
import org.xhtmlrenderer.swing.*;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.net.URI;
import java.nio.file.Path;
import static com.scrivenvar.Constants.*;
import static java.awt.Desktop.Action.BROWSE;
import static java.awt.Desktop.getDesktop;
import static org.xhtmlrenderer.swing.ImageResourceLoader.NO_OP_REPAINT_LISTENER;
public final class HTMLPreviewPane extends Pane {
private final static Notifier NOTIFIER = Services.load( Notifier.class );
private static class HTMLPanel extends XHTMLPanel {
@Override
public void resetScrollPosition() {
}
}
private static final class DocumentEventHandler implements DocumentListener {
private final BooleanProperty mReadyProperty = new SimpleBooleanProperty();
public BooleanProperty readyProperty() {
return mReadyProperty;
}
@Override
public void documentStarted() {
mReadyProperty.setValue( Boolean.FALSE );
}
@Override
public void documentLoaded() {
mReadyProperty.setValue( Boolean.TRUE );
}
@Override
public void onLayoutException( final Throwable t ) {
}
@Override
public void onRenderException( final Throwable t ) {
}
}
private final class ResizeListener implements ComponentListener {
@Override
public void componentResized( final ComponentEvent e ) {
setWidth( e );
}
@Override
public void componentShown( final ComponentEvent e ) {
setWidth( e );
}
@Override
public void componentMoved( final ComponentEvent e ) {
}
@Override
public void componentHidden( final ComponentEvent e ) {
}
private void setWidth( final ComponentEvent e ) {
final int width = (int) (e.getComponent().getWidth() * .95);
HTMLPreviewPane.this.mImageLoader.widthProperty().set( width );
}
}
private static class HyperlinkListener extends LinkListener {
@Override
public void linkClicked( final BasicPanel panel, final String uri ) {
try {
final var desktop = getDesktop();
if( desktop.isSupported( BROWSE ) ) {
desktop.browse( new URI( uri ) );
}
} catch( final Exception e ) {
NOTIFIER.notify( e );
}
}
}
private final static String HTML_HEADER = "<!DOCTYPE html>"
+ "<html>"
+ "<head>"
+ "<link rel='stylesheet' href='" +
HTMLPreviewPane.class.getResource( STYLESHEET_PREVIEW ) + "'/>"
+ "</head>"
+ "<body>";
private final static String HTML_FOOTER =
"<p style='height=2em'> </p></body></html>";
private final static W3CDom W3C_DOM = new W3CDom();
private final static XhtmlNamespaceHandler NS_HANDLER =
new XhtmlNamespaceHandler();
private final StringBuilder mHtmlDocument = new StringBuilder( 65536 );
private final int mHtmlPrefixLength;
private final HTMLPanel mHtmlRenderer = new HTMLPanel();
private final SwingNode mSwingNode = new SwingNode();
private final JScrollPane mScrollPane = new JScrollPane( mHtmlRenderer );
private final DocumentEventHandler mDocHandler = new DocumentEventHandler();
private final CustomImageLoader mImageLoader = new CustomImageLoader();
private Path mPath = DEFAULT_DIRECTORY;
public HTMLPreviewPane() {
setStyle( "-fx-background-color: white;" );
mHtmlDocument.append( HTML_HEADER );
mHtmlPrefixLength = mHtmlDocument.length();
final var factory = new ChainedReplacedElementFactory();
factory.addFactory( new SVGReplacedElementFactory() );
factory.addFactory( new SwingReplacedElementFactory(
NO_OP_REPAINT_LISTENER, mImageLoader ) );
final var context = getSharedContext();
context.setReplacedElementFactory( factory );
context.getTextRenderer().setSmoothingThreshold( 0 );
mSwingNode.setContent( mScrollPane );
mSwingNode.setCache( true );
mHtmlRenderer.addDocumentListener( mDocHandler );
mHtmlRenderer.addComponentListener( new ResizeListener() );
for( final var listener : mHtmlRenderer.getMouseTrackingListeners() ) {
if( !(listener instanceof HoverListener) ) {
mHtmlRenderer.removeMouseTrackingListener( (FSMouseListener) listener );
}
}
mHtmlRenderer.addMouseTrackingListener( new HyperlinkListener() );
}
public void process( final String html ) {
final Document jsoupDoc = Jsoup.parse( decorate( html ) );
final org.w3c.dom.Document w3cDoc = W3C_DOM.fromJsoup( jsoupDoc );
mHtmlRenderer.setDocument( w3cDoc, getBaseUrl(), NS_HANDLER );
}
public void clear() {
process( "" );
}
public void tryScrollTo( final int id ) {
final ChangeListener<Boolean> listener = new ChangeListener<>() {
@Override
public void changed(
final ObservableValue<? extends Boolean> observable,
final Boolean oldValue,
final Boolean newValue ) {
if( newValue ) {
scrollTo( id );
mDocHandler.readyProperty().removeListener( this );
}
}
};
mDocHandler.readyProperty().addListener( listener );
}
public void scrollTo( final int id ) {
if( id < 2 ) {
scrollToTop();
}
else {
Box box = findPrevBox( id );
box = box == null ? findNextBox( id + 1 ) : box;
if( box == null ) {
scrollToBottom();
}
else {
scrollTo( box );
}
}
}
private Box findPrevBox( final int id ) {
int prevId = id;
Box box = null;
while( prevId > 0 && (box = getBoxById( PARAGRAPH_ID_PREFIX + prevId )) == null ) {
prevId--;
}
return box;
}
private Box findNextBox( final int id ) {
int nextId = id;
Box box = null;
while( nextId - id < 5 &&
(box = getBoxById( PARAGRAPH_ID_PREFIX + nextId )) == null ) {
nextId++;
}
return box;
}
private void scrollTo( final Point point ) {
mHtmlRenderer.scrollTo( point );
}
private void scrollTo( final Box box ) {
scrollTo( createPoint( box ) );
}
private void scrollToY( final int y ) {
scrollTo( new Point( 0, y ) );
}
private void scrollToTop() {
scrollToY( 0 );
}
private void scrollToBottom() {
scrollToY( mHtmlRenderer.getHeight() );
}
private Box getBoxById( final String id ) {
return getSharedContext().getBoxById( id );
}
private String decorate( final String html ) {
mHtmlDocument.setLength( mHtmlPrefixLength );
return mHtmlDocument.append( html )
.append( HTML_FOOTER )
.toString();
}
public Path getPath() {
return mPath;
}
public void setPath( final Path path ) {
assert path != null;
mPath = path;
}
public Node getNode() {
return mSwingNode;
}
public JScrollPane getScrollPane() {
return mScrollPane;
}
public JScrollBar getVerticalScrollBar() {
return getScrollPane().getVerticalScrollBar();
}
private Point createPoint( final Box box ) {
assert box != null;
int x = box.getAbsX();
int y = Math.max(
box.getAbsY() - (mScrollPane.getVerticalScrollBar().getHeight() / 2),
0 );
if( !box.getStyle().isInline() ) {
final var margin = box.getMargin( mHtmlRenderer.getLayoutContext() );
x += margin.left();
y += margin.top();
}
return new Point( x, y );
}
private String getBaseUrl() {
final Path basePath = getPath();
final Path parent = basePath == null ? null : basePath.getParent();
return parent == null ? "" : parent.toUri().toString();
}
private SharedContext getSharedContext() {
return mHtmlRenderer.getSharedContext();
}
}