Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git

Refactor in preparation for CSSBox to replace FlyingSaucer

AuthorDaveJarvis <email>
Date2021-11-06 13:31:57 GMT-0700
Commit8a29bc60bfce9193aaec58608a70feded1b038f4
Parentb2e3124
Delta565 lines added, 410 lines removed, 155-line increase
src/main/java/com/keenwrite/ui/heuristics/DocumentStatistics.java
import com.keenwrite.events.DocumentChangedEvent;
import com.keenwrite.preferences.Workspace;
-import com.keenwrite.preview.HtmlPanel;
+import com.keenwrite.preview.HtmlPanelImpl;
import com.whitemagicsoftware.wordcount.TokenizerException;
import javafx.beans.property.IntegerProperty;
* Called when the hash code for the current document changes. This happens
* when non-collapsable-whitespace is added to the document. When the
- * document is sent to {@link HtmlPanel} for rendering, the parsed document
+ * document is sent to {@link HtmlPanelImpl} for rendering, the parsed document
* is converted to text. If that text differs in its hash code, then this
* method is called. The implication is that all variables and executable
src/main/java/com/keenwrite/preview/HtmlPanelImpl.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.preview;
+
+import com.keenwrite.dom.DocumentConverter;
+import com.keenwrite.ui.adapters.DocumentAdapter;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+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.DocumentChangedEvent.fireDocumentChangedEvent;
+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 javax.swing.SwingUtilities.invokeLater;
+import static javax.swing.SwingUtilities.isEventDispatchThread;
+import static org.jsoup.Jsoup.parse;
+
+/**
+ * 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 DocumentConverter CONVERTER = new DocumentConverter();
+ 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 html A complete HTML5 document, including doctype.
+ * @param baseUri URI to use for finding relative files, such as images.
+ */
+ void render( final String html, final String baseUri ) {
+ final var soup = parse( html );
+ final var doc = CONVERTER.fromJsoup( soup );
+ final Runnable renderDocument = () -> setDocument( doc, baseUri, XNH );
+ doc.setDocumentURI( baseUri );
+
+ // Access to a Swing component must occur from the Event Dispatch
+ // Thread (EDT) according to Swing threading restrictions. Setting a new
+ // document invokes a Swing repaint operation.
+ if( isEventDispatchThread() ) {
+ renderDocument.run();
+ }
+ else {
+ invokeLater( renderDocument );
+ }
+
+ // When the text changes, let subscribers know. This allows for text
+ // analysis to occur on a separate thread.
+ fireDocumentChangedEvent( soup );
+ }
+
+ @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 );
+ }
+ }
+ }
+}
src/main/java/com/keenwrite/preview/HtmlPreview.java
import org.greenrobot.eventbus.Subscribe;
import org.xhtmlrenderer.render.Box;
-import org.xhtmlrenderer.swing.SwingReplacedElementFactory;
-
-import javax.swing.*;
-import java.awt.*;
-import java.awt.event.ComponentEvent;
-import java.awt.event.ComponentListener;
-import java.net.URL;
-import java.nio.file.Path;
-import java.util.Locale;
-
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.constants.Constants.*;
-import static com.keenwrite.events.Bus.register;
-import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.preferences.WorkspaceKeys.*;
-import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
-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;
-import static javax.swing.KeyStroke.getKeyStroke;
-import static javax.swing.SwingUtilities.invokeLater;
-import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK;
-import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT;
-
-/**
- * Responsible for parsing an HTML document.
- */
-public final class HtmlPreview extends SwingNode implements ComponentListener {
- /**
- * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
- */
- private static final String HTML_STYLESHEET =
- "<link rel='stylesheet' href='%s'/>";
-
- private static final String HTML_BASE =
- "<base href='%s'/>";
-
- /**
- * Render CSS using points (pt) not pixels (px) to reduce the chance of
- * poor rendering. The {@link #generateHead()} method fills placeholders.
- * When the user has not set a locale, only one stylesheet is added to
- * the document. In order, the placeholders are as follows:
- * <ol>
- * <li>%s --- language</li>
- * <li>%s --- default stylesheet</li>
- * <li>%s --- language-specific stylesheet</li>
- * <li>%s --- user-customized stylesheet</li>
- * <li>%s --- font family</li>
- * <li>%d --- font size (must be pixels, not points due to bug)</li>
- * <li>%s --- base href</li>
- * </p>
- */
- private static final String HTML_HEAD =
- """
- <!doctype html>
- <html lang='%s'><head><title> </title><meta charset='utf-8'/>
- %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
- """;
-
- private static final String HTML_TAIL = "</body></html>";
-
- private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
-
- private final ChainedReplacedElementFactory mFactory;
-
- /**
- * Reusing this buffer prevents repetitious memory re-allocations.
- */
- private final StringBuilder mDocument = new StringBuilder( 65536 );
-
- private HtmlPanel mView;
- private JScrollPane mScrollPane;
- private String mBaseUriPath = "";
- private String mHead = "";
-
- private volatile boolean mLocked;
- private final JButton mScrollLockButton = new JButton();
- private final Workspace mWorkspace;
-
- /**
- * Creates a new preview pane that can scroll to the caret position within the
- * document.
- *
- * @param workspace Contains locale and font size information.
- */
- public HtmlPreview( final Workspace workspace ) {
- mWorkspace = workspace;
-
- // 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()
- );
-
- // Attempts to prevent a flash of black un-styled content upon load.
- setStyle( "-fx-background-color: white;" );
-
- invokeLater( () -> {
- mHead = generateHead();
- mView = new HtmlPanel();
- mScrollPane = new JScrollPane( mView );
- final var verticalBar = mScrollPane.getVerticalScrollBar();
- final var verticalPanel = new JPanel( new BorderLayout() );
-
- final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
- addKeyboardEvents( map );
-
- mScrollLockButton.setFont( getIconFont( 14 ) );
- mScrollLockButton.setText( getLockText( mLocked ) );
- mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
- mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
-
- verticalPanel.add( verticalBar, CENTER );
- verticalPanel.add( mScrollLockButton, PAGE_END );
-
- final var wrapper = new JPanel( new BorderLayout() );
- wrapper.add( mScrollPane, CENTER );
- wrapper.add( verticalPanel, LINE_END );
-
- // Enabling the cache attempts to prevent black flashes when resizing.
- setCache( true );
- setCacheHint( SPEED );
- setContent( wrapper );
- wrapper.addComponentListener( this );
-
- final var context = mView.getSharedContext();
- final var textRenderer = context.getTextRenderer();
- context.setReplacedElementFactory( mFactory );
- textRenderer.setSmoothingThreshold( 0 );
-
- localeProperty().addListener( ( c, o, n ) -> rerender() );
- fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
- fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
- } );
-
- register( this );
- }
-
- @Subscribe
- public void handle( final ScrollLockEvent event ) {
- mLocked = event.isLocked();
- invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
- }
-
- /**
- * Updates the internal HTML source shown in the preview pane.
- *
- * @param html The new HTML document to display.
- */
- public void render( final String html ) {
- mView.render( decorate( html ), getBaseUri() );
- }
-
- /**
- * Clears the caches then re-renders the content.
- */
- public void refresh() {
- mFactory.clearCache();
- rerender();
- }
-
- /**
- * Recomputes the HTML head then renders the document.
- */
- private void rerender() {
- mHead = generateHead();
- render( mDocument.toString() );
- }
-
- /**
- * Attaches the HTML head prefix and HTML tail suffix to the given HTML
- * string.
- *
- * @param html The HTML to adorn with opening and closing tags.
- * @return A complete HTML document, ready for rendering.
- */
- private String decorate( final String html ) {
- mDocument.setLength( 0 );
- mDocument.append( html );
-
- // Head and tail must be separate from document due to re-rendering.
- return mHead + mDocument + HTML_TAIL;
- }
-
- /**
- * Called when settings are changed that affect the HTML document preamble.
- * This is a minor performance optimization to avoid generating the head
- * each time that the document itself changes.
- *
- * @return A new doctype and HTML {@code head} element.
- */
- private String generateHead() {
- final var locale = getLocale();
- final var base = getBaseUri();
- final var custom = getCustomStylesheetUrl();
-
- // Point sizes are converted to pixels because of a rendering bug.
- return format(
- HTML_HEAD,
- locale.getLanguage(),
- toStylesheetString( HTML_STYLE_PREVIEW ),
- toStylesheetString( toUrl( locale ) ),
- toStylesheetString( custom ),
- getFontFamily(),
- toPixels( getFontSize() ),
- base.isBlank() ? "" : format( HTML_BASE, base )
- );
- }
-
- /**
- * Clears the preview pane by rendering an empty string.
- */
- public void clear() {
- render( "" );
- }
-
- /**
- * Sets the base URI to the containing directory the file being edited.
- *
- * @param path The path to the file being edited.
- */
- public void setBaseUri( final Path path ) {
- final var parent = path.getParent();
- mBaseUriPath = parent == null ? "" : parent.toUri().toString();
- }
-
- /**
- * Scrolls to the closest element matching the given identifier without
- * waiting for the document to be ready.
- *
- * @param id Scroll the preview pane to this unique paragraph identifier.
- */
- public void scrollTo( final String id ) {
- if( mLocked ) {
- return;
- }
-
- invokeLater( () -> {
- int iter = 0;
- Box box = null;
-
- while( iter++ < 3 && ((box = mView.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 ) {
- invokeLater( () -> {
- mView.scrollTo( createPoint( box ) );
- getScrollPane().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( mView.getLayoutContext() );
- y += margin.top();
- x += margin.left();
- }
-
- return new Point( x, y );
- }
-
- private String getBaseUri() {
- return mBaseUriPath;
- }
-
- private JScrollPane getScrollPane() {
- return mScrollPane;
- }
-
- public JScrollBar getVerticalScrollBar() {
- return getScrollPane().getVerticalScrollBar();
- }
-
- private int getVerticalScrollBarHeight() {
- return getVerticalScrollBar().getHeight();
- }
-
- /**
- * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
- * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
- * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
- * could return "en-Latn-CA" for Canadian English written in the Latin
- * character set.
- *
- * @return Unique identifier for language and country.
- */
- private static URL toUrl( final Locale locale ) {
- return toUrl(
- get(
- sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
- locale.getLanguage(),
- locale.getScript(),
- locale.getCountry()
- )
- );
- }
-
- private static URL toUrl( final String path ) {
- return HtmlPreview.class.getResource( path );
- }
-
- private Locale getLocale() {
- return localeProperty().toLocale();
- }
-
- private LocaleProperty localeProperty() {
- return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
- }
-
- private String getFontFamily() {
- return fontFamilyProperty().get();
- }
-
- private StringProperty fontFamilyProperty() {
- return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
- }
-
- private double getFontSize() {
- return fontSizeProperty().get();
- }
-
- /**
- * Returns the font size in points.
- *
- * @return The user-defined font size (in pt).
- */
- private DoubleProperty fontSizeProperty() {
- return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
- }
-
- private String getLockText( final boolean locked ) {
- return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
- }
-
- private URL getCustomStylesheetUrl() {
- try {
- return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL();
- } catch( final Exception ex ) {
- clue( ex );
- return null;
- }
- }
-
- /**
- * Maps keyboard events to scrollbar commands so that users may control
- * the {@link HtmlPreview} panel using the keyboard.
- *
- * @param map The map to update with keyboard events.
- */
- private void addKeyboardEvents( final InputMap map ) {
- map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
- map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
- map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
- map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
- map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
- map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
- }
-
- @Override
- public void componentResized( final ComponentEvent e ) {
- if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
- mFactory.clearCache();
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ComponentEvent;
+import java.awt.event.ComponentListener;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Locale;
+
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.Constants.*;
+import static com.keenwrite.events.Bus.register;
+import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.preferences.WorkspaceKeys.*;
+import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
+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;
+import static javax.swing.KeyStroke.getKeyStroke;
+import static javax.swing.SwingUtilities.invokeLater;
+import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK;
+import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT;
+
+/**
+ * Responsible for parsing an HTML document.
+ */
+public final class HtmlPreview extends SwingNode implements ComponentListener {
+ /**
+ * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
+ */
+ private static final String HTML_STYLESHEET =
+ "<link rel='stylesheet' href='%s'/>";
+
+ private static final String HTML_BASE =
+ "<base href='%s'/>";
+
+ /**
+ * Render CSS using points (pt) not pixels (px) to reduce the chance of
+ * poor rendering. The {@link #generateHead()} method fills placeholders.
+ * When the user has not set a locale, only one stylesheet is added to
+ * the document. In order, the placeholders are as follows:
+ * <ol>
+ * <li>%s --- language</li>
+ * <li>%s --- default stylesheet</li>
+ * <li>%s --- language-specific stylesheet</li>
+ * <li>%s --- user-customized stylesheet</li>
+ * <li>%s --- font family</li>
+ * <li>%d --- font size (must be pixels, not points due to bug)</li>
+ * <li>%s --- base href</li>
+ * </p>
+ */
+ private static final String HTML_HEAD =
+ """
+ <!doctype html>
+ <html lang='%s'><head><title> </title><meta charset='utf-8'/>
+ %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
+ """;
+
+ private static final String HTML_TAIL = "</body></html>";
+
+ private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
+
+ /**
+ * Reusing this buffer prevents repetitious memory re-allocations.
+ */
+ private final StringBuilder mDocument = new StringBuilder( 65536 );
+
+ private HtmlPanelImpl mPreview;
+ private JScrollPane mScrollPane;
+ private String mBaseUriPath = "";
+ private String mHead = "";
+
+ private volatile boolean mLocked;
+ private final JButton mScrollLockButton = new JButton();
+ private final Workspace mWorkspace;
+
+ /**
+ * Creates a new preview pane that can scroll to the caret position within the
+ * document.
+ *
+ * @param workspace Contains locale and font size information.
+ */
+ public HtmlPreview( final Workspace workspace ) {
+ mWorkspace = workspace;
+
+ // Attempts to prevent a flash of black un-styled content upon load.
+ setStyle( "-fx-background-color: white;" );
+
+ invokeLater( () -> {
+ mHead = generateHead();
+ mPreview = new HtmlPanelImpl();
+ mScrollPane = new JScrollPane( mPreview );
+ final var verticalBar = mScrollPane.getVerticalScrollBar();
+ final var verticalPanel = new JPanel( new BorderLayout() );
+
+ final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
+ addKeyboardEvents( map );
+
+ mScrollLockButton.setFont( getIconFont( 14 ) );
+ mScrollLockButton.setText( getLockText( mLocked ) );
+ mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
+ mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
+
+ verticalPanel.add( verticalBar, CENTER );
+ verticalPanel.add( mScrollLockButton, PAGE_END );
+
+ final var wrapper = new JPanel( new BorderLayout() );
+ wrapper.add( mScrollPane, CENTER );
+ wrapper.add( verticalPanel, LINE_END );
+
+ // Enabling the cache attempts to prevent black flashes when resizing.
+ setCache( true );
+ setCacheHint( SPEED );
+ setContent( wrapper );
+ wrapper.addComponentListener( this );
+
+ localeProperty().addListener( ( c, o, n ) -> rerender() );
+ fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
+ fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
+ } );
+
+ register( this );
+ }
+
+ @Subscribe
+ public void handle( final ScrollLockEvent event ) {
+ mLocked = event.isLocked();
+ invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
+ }
+
+ /**
+ * Updates the internal HTML source shown in the preview pane.
+ *
+ * @param html The new HTML document to display.
+ */
+ public void render( final String html ) {
+ mPreview.render( decorate( html ), getBaseUri() );
+ }
+
+ /**
+ * Clears the caches then re-renders the content.
+ */
+ public void refresh() {
+ mPreview.clearCache();
+ rerender();
+ }
+
+ /**
+ * Recomputes the HTML head then renders the document.
+ */
+ private void rerender() {
+ mHead = generateHead();
+ render( mDocument.toString() );
+ }
+
+ /**
+ * Attaches the HTML head prefix and HTML tail suffix to the given HTML
+ * string.
+ *
+ * @param html The HTML to adorn with opening and closing tags.
+ * @return A complete HTML document, ready for rendering.
+ */
+ private String decorate( final String html ) {
+ mDocument.setLength( 0 );
+ mDocument.append( html );
+
+ // Head and tail must be separate from document due to re-rendering.
+ return mHead + mDocument + HTML_TAIL;
+ }
+
+ /**
+ * Called when settings are changed that affect the HTML document preamble.
+ * This is a minor performance optimization to avoid generating the head
+ * each time that the document itself changes.
+ *
+ * @return A new doctype and HTML {@code head} element.
+ */
+ private String generateHead() {
+ final var locale = getLocale();
+ final var base = getBaseUri();
+ final var custom = getCustomStylesheetUrl();
+
+ // Point sizes are converted to pixels because of a rendering bug.
+ return format(
+ HTML_HEAD,
+ locale.getLanguage(),
+ toStylesheetString( HTML_STYLE_PREVIEW ),
+ toStylesheetString( toUrl( locale ) ),
+ toStylesheetString( custom ),
+ getFontFamily(),
+ toPixels( getFontSize() ),
+ base.isBlank() ? "" : format( HTML_BASE, base )
+ );
+ }
+
+ /**
+ * Clears the preview pane by rendering an empty string.
+ */
+ public void clear() {
+ render( "" );
+ }
+
+ /**
+ * Sets the base URI to the containing directory the file being edited.
+ *
+ * @param path The path to the file being edited.
+ */
+ public void setBaseUri( final Path path ) {
+ final var parent = path.getParent();
+ mBaseUriPath = parent == null ? "" : parent.toUri().toString();
+ }
+
+ /**
+ * Scrolls to the closest element matching the given identifier without
+ * waiting for the document to be ready.
+ *
+ * @param id Scroll the preview pane to this unique paragraph identifier.
+ */
+ 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 ) {
+ invokeLater( () -> {
+ mPreview.scrollTo( createPoint( box ) );
+ getScrollPane().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 );
+ }
+
+ private String getBaseUri() {
+ return mBaseUriPath;
+ }
+
+ private JScrollPane getScrollPane() {
+ return mScrollPane;
+ }
+
+ public JScrollBar getVerticalScrollBar() {
+ return getScrollPane().getVerticalScrollBar();
+ }
+
+ private int getVerticalScrollBarHeight() {
+ return getVerticalScrollBar().getHeight();
+ }
+
+ /**
+ * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
+ * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
+ * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
+ * could return "en-Latn-CA" for Canadian English written in the Latin
+ * character set.
+ *
+ * @return Unique identifier for language and country.
+ */
+ private static URL toUrl( final Locale locale ) {
+ return toUrl(
+ get(
+ sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
+ locale.getLanguage(),
+ locale.getScript(),
+ locale.getCountry()
+ )
+ );
+ }
+
+ private static URL toUrl( final String path ) {
+ return HtmlPreview.class.getResource( path );
+ }
+
+ private Locale getLocale() {
+ return localeProperty().toLocale();
+ }
+
+ private LocaleProperty localeProperty() {
+ return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
+ }
+
+ private String getFontFamily() {
+ return fontFamilyProperty().get();
+ }
+
+ private StringProperty fontFamilyProperty() {
+ return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
+ }
+
+ private double getFontSize() {
+ return fontSizeProperty().get();
+ }
+
+ /**
+ * Returns the font size in points.
+ *
+ * @return The user-defined font size (in pt).
+ */
+ private DoubleProperty fontSizeProperty() {
+ return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
+ }
+
+ private String getLockText( final boolean locked ) {
+ return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
+ }
+
+ private URL getCustomStylesheetUrl() {
+ try {
+ return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL();
+ } catch( final Exception ex ) {
+ clue( ex );
+ return null;
+ }
+ }
+
+ /**
+ * Maps keyboard events to scrollbar commands so that users may control
+ * the {@link HtmlPreview} panel using the keyboard.
+ *
+ * @param map The map to update with keyboard events.
+ */
+ private void addKeyboardEvents( final InputMap map ) {
+ map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
+ map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
+ map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
+ map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
+ map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
+ map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
+ }
+
+ @Override
+ public void componentResized( final ComponentEvent e ) {
+ if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
+ mPreview.clearCache();
}