| +package com.keenwrite.preview; | ||
| + | ||
| +import org.w3c.dom.Document; | ||
| + | ||
| +import javax.swing.*; | ||
| + | ||
| +public class CssBoxPanel implements HtmlPanel { | ||
| + | ||
| + @Override | ||
| + public void render( final Document doc, final String baseUri ) { | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void scrollTo( final String id, final JScrollPane scrollPane ) { | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void clearCache() { | ||
| + } | ||
| +} | ||
| +/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | ||
| +package com.keenwrite.preview; | ||
| + | ||
| +import com.keenwrite.ui.adapters.DocumentAdapter; | ||
| +import javafx.beans.property.BooleanProperty; | ||
| +import javafx.beans.property.SimpleBooleanProperty; | ||
| +import org.w3c.dom.Document; | ||
| +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.ComponentAdapter; | ||
| +import java.awt.event.ComponentEvent; | ||
| +import java.net.URI; | ||
| + | ||
| +import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent; | ||
| +import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | ||
| +import static com.keenwrite.events.StatusEvent.clue; | ||
| +import static com.keenwrite.util.ProtocolScheme.getProtocol; | ||
| +import static java.lang.Boolean.FALSE; | ||
| +import static java.lang.Boolean.TRUE; | ||
| +import static java.lang.Math.max; | ||
| +import static java.lang.Thread.sleep; | ||
| +import static javax.swing.SwingUtilities.invokeLater; | ||
| + | ||
| +/** | ||
| + * Responsible for configuring FlyingSaucer's {@link XHTMLPanel}. | ||
| + */ | ||
| +public final class FlyingSaucerPanel extends XHTMLPanel implements HtmlPanel { | ||
| + | ||
| + /** | ||
| + * Suppresses scroll attempts until after the document has loaded. | ||
| + */ | ||
| + private static final class DocumentEventHandler extends DocumentAdapter { | ||
| + private final BooleanProperty mReadyProperty = new SimpleBooleanProperty(); | ||
| + | ||
| + @Override | ||
| + public void documentStarted() { | ||
| + mReadyProperty.setValue( FALSE ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void documentLoaded() { | ||
| + mReadyProperty.setValue( TRUE ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Ensures that the preview panel fills its container's area completely. | ||
| + */ | ||
| + private final class ComponentEventHandler extends ComponentAdapter { | ||
| + /** | ||
| + * Invoked when the component's size changes. | ||
| + */ | ||
| + public void componentResized( final ComponentEvent e ) { | ||
| + setPreferredSize( e.getComponent().getPreferredSize() ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Responsible for opening hyperlinks. External hyperlinks are opened in | ||
| + * the system's default browser; local file system links are opened in the | ||
| + * editor. | ||
| + */ | ||
| + private static final class HyperlinkListener extends LinkListener { | ||
| + @Override | ||
| + public void linkClicked( final BasicPanel panel, final String link ) { | ||
| + try { | ||
| + final var uri = new URI( link ); | ||
| + | ||
| + switch( getProtocol( uri ) ) { | ||
| + case HTTP -> fireHyperlinkOpenEvent( uri ); | ||
| + case FILE -> fireFileOpenEvent( uri ); | ||
| + } | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + private static final XhtmlNamespaceHandler XNH = new XhtmlNamespaceHandler(); | ||
| + private final ChainedReplacedElementFactory mFactory; | ||
| + | ||
| + FlyingSaucerPanel() { | ||
| + // The order is important: SwingReplacedElementFactory replaces SVG images | ||
| + // with a blank image, which will cause the chained factory to cache the | ||
| + // image and exit. Instead, the SVG must execute first to rasterize the | ||
| + // content. Consequently, the chained factory must maintain insertion order. | ||
| + mFactory = new ChainedReplacedElementFactory( | ||
| + new SvgReplacedElementFactory(), | ||
| + new SwingReplacedElementFactory() | ||
| + ); | ||
| + | ||
| + final var context = getSharedContext(); | ||
| + final var textRenderer = context.getTextRenderer(); | ||
| + context.setReplacedElementFactory( mFactory ); | ||
| + textRenderer.setSmoothingThreshold( 0 ); | ||
| + | ||
| + addDocumentListener( new DocumentEventHandler() ); | ||
| + removeMouseTrackingListeners(); | ||
| + addMouseTrackingListener( new HyperlinkListener() ); | ||
| + addComponentListener( new ComponentEventHandler() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Updates the document model displayed by the renderer. Effectively, this | ||
| + * updates the HTML document to provide new content. | ||
| + * | ||
| + * @param doc A complete HTML5 document, including doctype. | ||
| + * @param baseUri URI to use for finding relative files, such as images. | ||
| + */ | ||
| + @Override | ||
| + public void render( final Document doc, final String baseUri ) { | ||
| + setDocument( doc, baseUri, XNH ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void clearCache() { | ||
| + mFactory.clearCache(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void scrollTo(final String id, final JScrollPane scrollPane) { | ||
| + int iter = 0; | ||
| + Box box = null; | ||
| + | ||
| + while( iter++ < 3 && ((box = getBoxById( id )) == null) ) { | ||
| + try { | ||
| + sleep( 10 ); | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + } | ||
| + } | ||
| + | ||
| + scrollTo( box, scrollPane ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Scrolls to the location specified by the {@link Box} that corresponds | ||
| + * to a point somewhere in the preview pane. If there is no caret, then | ||
| + * this will not change the scroll position. Changing the scroll position | ||
| + * to the top if the {@link Box} instance is {@code null} will result in | ||
| + * jumping around a lot and inconsistent synchronization issues. | ||
| + * | ||
| + * @param box The rectangular region containing the caret, or {@code null} | ||
| + * if the HTML does not have a caret. | ||
| + */ | ||
| + private void scrollTo( final Box box, final JScrollPane scrollPane ) { | ||
| + if( box != null ) { | ||
| + invokeLater( () -> { | ||
| + scrollTo( createPoint( box, scrollPane ) ); | ||
| + scrollPane.repaint(); | ||
| + } ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a {@link Point} to use as a reference for scrolling to the area | ||
| + * described by the given {@link Box}. The {@link Box} coordinates are used | ||
| + * to populate the {@link Point}'s location, with minor adjustments for | ||
| + * vertical centering. | ||
| + * | ||
| + * @param box The {@link Box} that represents a scrolling anchor reference. | ||
| + * @return A coordinate suitable for scrolling to. | ||
| + */ | ||
| + private Point createPoint( final Box box, final JScrollPane scrollPane ) { | ||
| + assert box != null; | ||
| + | ||
| + // Scroll back up by half the height of the scroll bar to keep the typing | ||
| + // area within the view port. Otherwise the view port will have jumped too | ||
| + // high up and the most recently typed letters won't be visible. | ||
| + int y = max( box.getAbsY() - scrollPane.getVerticalScrollBar().getHeight() / 2, 0 ); | ||
| + int x = box.getAbsX(); | ||
| + | ||
| + if( !box.getStyle().isInline() ) { | ||
| + final var margin = box.getMargin( getLayoutContext() ); | ||
| + y += margin.top(); | ||
| + x += margin.left(); | ||
| + } | ||
| + | ||
| + return new Point( x, y ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Delegates to the {@link SharedContext}. | ||
| + * | ||
| + * @param id The HTML element identifier to retrieve in {@link Box} form. | ||
| + * @return The {@link Box} that corresponds to the given element ID, or | ||
| + * {@code null} if none found. | ||
| + */ | ||
| + Box getBoxById( final String id ) { | ||
| + return getSharedContext().getBoxById( id ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Suppress scrolling to the top on updates. | ||
| + */ | ||
| + @Override | ||
| + public void resetScrollPosition() { | ||
| + } | ||
| + | ||
| + /** | ||
| + * The default mouse click listener attempts navigation within the preview | ||
| + * panel. We want to usurp that behaviour to open the link in a | ||
| + * platform-specific browser. | ||
| + */ | ||
| + private void removeMouseTrackingListeners() { | ||
| + for( final var listener : getMouseTrackingListeners() ) { | ||
| + if( !(listener instanceof HoverListener) ) { | ||
| + removeMouseTrackingListener( (FSMouseListener) listener ); | ||
| + } | ||
| + } | ||
| + } | ||
| +} | ||
| package com.keenwrite.preview; | ||
| +import org.w3c.dom.Document; | ||
| + | ||
| +import javax.swing.*; | ||
| + | ||
| public interface HtmlPanel { | ||
| + | ||
| + /** | ||
| + * Renders an HTML document with respect to a base location. | ||
| + * | ||
| + * @param doc The document to render. | ||
| + * @param baseUri The document's relative URI. | ||
| + */ | ||
| + void render( final Document doc, final String baseUri ); | ||
| + | ||
| + /** | ||
| + * Scrolls the given {@link JScrollPane} to the first HTML element that | ||
| + * has an {@code id} attribute that matches the given identifier. | ||
| + * | ||
| + * @param id The HTML element identifier. | ||
| + * @param scrollPane The GUI widget that controls scrolling. | ||
| + */ | ||
| + void scrollTo( final String id, final JScrollPane scrollPane ); | ||
| /** |
| -/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | ||
| -package com.keenwrite.preview; | ||
| - | ||
| -import com.keenwrite.ui.adapters.DocumentAdapter; | ||
| -import javafx.beans.property.BooleanProperty; | ||
| -import javafx.beans.property.SimpleBooleanProperty; | ||
| -import org.w3c.dom.Document; | ||
| -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 java.awt.event.ComponentAdapter; | ||
| -import java.awt.event.ComponentEvent; | ||
| -import java.net.URI; | ||
| - | ||
| -import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent; | ||
| -import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | ||
| -import static com.keenwrite.events.StatusEvent.clue; | ||
| -import static com.keenwrite.util.ProtocolScheme.getProtocol; | ||
| -import static java.lang.Boolean.FALSE; | ||
| -import static java.lang.Boolean.TRUE; | ||
| - | ||
| -/** | ||
| - * Responsible for configuring FlyingSaucer's {@link XHTMLPanel}. | ||
| - */ | ||
| -public final class HtmlPanelImpl extends XHTMLPanel implements HtmlPanel { | ||
| - | ||
| - /** | ||
| - * Suppresses scroll attempts until after the document has loaded. | ||
| - */ | ||
| - private static final class DocumentEventHandler extends DocumentAdapter { | ||
| - private final BooleanProperty mReadyProperty = new SimpleBooleanProperty(); | ||
| - | ||
| - @Override | ||
| - public void documentStarted() { | ||
| - mReadyProperty.setValue( FALSE ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void documentLoaded() { | ||
| - mReadyProperty.setValue( TRUE ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Ensures that the preview panel fills its container's area completely. | ||
| - */ | ||
| - private final class ComponentEventHandler extends ComponentAdapter { | ||
| - /** | ||
| - * Invoked when the component's size changes. | ||
| - */ | ||
| - public void componentResized( final ComponentEvent e ) { | ||
| - setPreferredSize( e.getComponent().getPreferredSize() ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Responsible for opening hyperlinks. External hyperlinks are opened in | ||
| - * the system's default browser; local file system links are opened in the | ||
| - * editor. | ||
| - */ | ||
| - private static final class HyperlinkListener extends LinkListener { | ||
| - @Override | ||
| - public void linkClicked( final BasicPanel panel, final String link ) { | ||
| - try { | ||
| - final var uri = new URI( link ); | ||
| - | ||
| - switch( getProtocol( uri ) ) { | ||
| - case HTTP -> fireHyperlinkOpenEvent( uri ); | ||
| - case FILE -> fireFileOpenEvent( uri ); | ||
| - } | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - private static final XhtmlNamespaceHandler XNH = new XhtmlNamespaceHandler(); | ||
| - private final ChainedReplacedElementFactory mFactory; | ||
| - | ||
| - HtmlPanelImpl() { | ||
| - // The order is important: SwingReplacedElementFactory replaces SVG images | ||
| - // with a blank image, which will cause the chained factory to cache the | ||
| - // image and exit. Instead, the SVG must execute first to rasterize the | ||
| - // content. Consequently, the chained factory must maintain insertion order. | ||
| - mFactory = new ChainedReplacedElementFactory( | ||
| - new SvgReplacedElementFactory(), | ||
| - new SwingReplacedElementFactory() | ||
| - ); | ||
| - | ||
| - final var context = getSharedContext(); | ||
| - final var textRenderer = context.getTextRenderer(); | ||
| - context.setReplacedElementFactory( mFactory ); | ||
| - textRenderer.setSmoothingThreshold( 0 ); | ||
| - | ||
| - addDocumentListener( new DocumentEventHandler() ); | ||
| - removeMouseTrackingListeners(); | ||
| - addMouseTrackingListener( new HyperlinkListener() ); | ||
| - addComponentListener( new ComponentEventHandler() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Updates the document model displayed by the renderer. Effectively, this | ||
| - * updates the HTML document to provide new content. | ||
| - * | ||
| - * @param doc A complete HTML5 document, including doctype. | ||
| - * @param baseUri URI to use for finding relative files, such as images. | ||
| - */ | ||
| - void render( final Document doc, final String baseUri ) { | ||
| - setDocument( doc, baseUri, XNH ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void clearCache() { | ||
| - mFactory.clearCache(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Delegates to the {@link SharedContext}. | ||
| - * | ||
| - * @param id The HTML element identifier to retrieve in {@link Box} form. | ||
| - * @return The {@link Box} that corresponds to the given element ID, or | ||
| - * {@code null} if none found. | ||
| - */ | ||
| - Box getBoxById( final String id ) { | ||
| - return getSharedContext().getBoxById( id ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Suppress scrolling to the top on updates. | ||
| - */ | ||
| - @Override | ||
| - public void resetScrollPosition() { | ||
| - } | ||
| - | ||
| - /** | ||
| - * The default mouse click listener attempts navigation within the preview | ||
| - * panel. We want to usurp that behaviour to open the link in a | ||
| - * platform-specific browser. | ||
| - */ | ||
| - private void removeMouseTrackingListeners() { | ||
| - for( final var listener : getMouseTrackingListeners() ) { | ||
| - if( !(listener instanceof HoverListener) ) { | ||
| - removeMouseTrackingListener( (FSMouseListener) listener ); | ||
| - } | ||
| - } | ||
| - } | ||
| -} | ||
| import javafx.embed.swing.SwingNode; | ||
| import org.greenrobot.eventbus.Subscribe; | ||
| -import org.xhtmlrenderer.render.Box; | ||
| import javax.swing.*; | ||
| import static java.awt.BorderLayout.*; | ||
| import static java.awt.event.KeyEvent.*; | ||
| -import static java.lang.Math.max; | ||
| import static java.lang.String.format; | ||
| -import static java.lang.Thread.sleep; | ||
| import static javafx.scene.CacheHint.SPEED; | ||
| import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW; | ||
| private final StringBuilder mDocument = new StringBuilder( 65536 ); | ||
| - private HtmlPanelImpl mPreview; | ||
| + private HtmlPanel mPreview; | ||
| private JScrollPane mScrollPane; | ||
| private String mBaseUriPath = ""; | ||
| invokeLater( () -> { | ||
| - mPreview = new HtmlPanelImpl(); | ||
| - mScrollPane = new JScrollPane( mPreview ); | ||
| + mPreview = new FlyingSaucerPanel(); | ||
| + mScrollPane = new JScrollPane( (Component) mPreview ); | ||
| final var verticalBar = mScrollPane.getVerticalScrollBar(); | ||
| final var verticalPanel = new JPanel( new BorderLayout() ); | ||
| */ | ||
| public void scrollTo( final String id ) { | ||
| - if( mLocked ) { | ||
| - return; | ||
| - } | ||
| - | ||
| - invokeLater( () -> { | ||
| - int iter = 0; | ||
| - Box box = null; | ||
| - | ||
| - while( iter++ < 3 && ((box = mPreview.getBoxById( id )) == null) ) { | ||
| - try { | ||
| - sleep( 10 ); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - } | ||
| - | ||
| - scrollTo( box ); | ||
| - } ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Scrolls to the location specified by the {@link Box} that corresponds | ||
| - * to a point somewhere in the preview pane. If there is no caret, then | ||
| - * this will not change the scroll position. Changing the scroll position | ||
| - * to the top if the {@link Box} instance is {@code null} will result in | ||
| - * jumping around a lot and inconsistent synchronization issues. | ||
| - * | ||
| - * @param box The rectangular region containing the caret, or {@code null} | ||
| - * if the HTML does not have a caret. | ||
| - */ | ||
| - private void scrollTo( final Box box ) { | ||
| - if( box != null ) { | ||
| + if( !mLocked ) { | ||
| invokeLater( () -> { | ||
| - mPreview.scrollTo( createPoint( box ) ); | ||
| - getScrollPane().repaint(); | ||
| + mPreview.scrollTo( id, mScrollPane ); | ||
| + mScrollPane.repaint(); | ||
| } ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a {@link Point} to use as a reference for scrolling to the area | ||
| - * described by the given {@link Box}. The {@link Box} coordinates are used | ||
| - * to populate the {@link Point}'s location, with minor adjustments for | ||
| - * vertical centering. | ||
| - * | ||
| - * @param box The {@link Box} that represents a scrolling anchor reference. | ||
| - * @return A coordinate suitable for scrolling to. | ||
| - */ | ||
| - private Point createPoint( final Box box ) { | ||
| - assert box != null; | ||
| - | ||
| - // Scroll back up by half the height of the scroll bar to keep the typing | ||
| - // area within the view port. Otherwise the view port will have jumped too | ||
| - // high up and the most recently typed letters won't be visible. | ||
| - int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 ); | ||
| - int x = box.getAbsX(); | ||
| - | ||
| - if( !box.getStyle().isInline() ) { | ||
| - final var margin = box.getMargin( mPreview.getLayoutContext() ); | ||
| - y += margin.top(); | ||
| - x += margin.left(); | ||
| } | ||
| - | ||
| - return new Point( x, y ); | ||
| } | ||
| public JScrollBar getVerticalScrollBar() { | ||
| return getScrollPane().getVerticalScrollBar(); | ||
| - } | ||
| - | ||
| - private int getVerticalScrollBarHeight() { | ||
| - return getVerticalScrollBar().getHeight(); | ||
| } | ||
| Author | DaveJarvis <email> |
|---|---|
| Date | 2021-11-06 19:38:14 GMT-0700 |
| Commit | bf2b611b541f32d736ed985132559a6351c5e66e |
| Parent | 509caf7 |
| Delta | 265 lines added, 221 lines removed, 44-line increase |