Dave Jarvis' Repositories

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

Merge pull request #102 from DaveJarvis/100_synchronize_caret_position 100 synchronize caret position

AuthorDave Jarvis <email>
Date2020-10-14 20:20:41 GMT-0700
Commit503c2f2c3574f1bea951803513a36673402e20b9
Parent4844221
.idea/misc.xml
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
- <component name="ProjectRootManager" version="2" languageLevel="JDK_14" default="false" project-jdk-name="14" project-jdk-type="JavaSDK">
+ <component name="ProjectRootManager" version="2" languageLevel="JDK_14" default="true" project-jdk-name="14" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build" />
</component>
build.gradle
dependencies {
+ def v_junit = '5.4.2'
+ def v_flexmark = '0.62.2'
+ def v_jackson = '2.11.2'
+ def v_batik = '1.13'
+
// JavaFX
implementation 'org.reactfx:reactfx:1.4.1'
// Markdown
- implementation 'com.vladsch.flexmark:flexmark:0.62.2'
- implementation 'com.vladsch.flexmark:flexmark-ext-definition:0.62.2'
- implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.62.2'
- implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.62.2'
- implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.62.2'
- implementation 'com.vladsch.flexmark:flexmark-ext-typographic:0.62.2'
+ implementation "com.vladsch.flexmark:flexmark:${v_flexmark}"
+ implementation "com.vladsch.flexmark:flexmark-ext-definition:${v_flexmark}"
+ implementation "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:${v_flexmark}"
+ implementation "com.vladsch.flexmark:flexmark-ext-superscript:${v_flexmark}"
+ implementation "com.vladsch.flexmark:flexmark-ext-tables:${v_flexmark}"
+ implementation "com.vladsch.flexmark:flexmark-ext-typographic:${v_flexmark}"
// YAML
- implementation 'com.fasterxml.jackson.core:jackson-core:2.11.2'
- implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.2'
- implementation 'com.fasterxml.jackson.core:jackson-annotations:2.11.2'
- implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.2'
+ implementation "com.fasterxml.jackson.core:jackson-core:${v_jackson}"
+ implementation "com.fasterxml.jackson.core:jackson-databind:${v_jackson}"
+ implementation "com.fasterxml.jackson.core:jackson-annotations:${v_jackson}"
+ implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${v_jackson}"
implementation 'org.yaml:snakeyaml:1.26'
// SVG
- implementation 'org.apache.xmlgraphics:batik-anim:1.13'
- implementation 'org.apache.xmlgraphics:batik-awt-util:1.13'
- implementation 'org.apache.xmlgraphics:batik-bridge:1.13'
- implementation 'org.apache.xmlgraphics:batik-css:1.13'
- implementation 'org.apache.xmlgraphics:batik-dom:1.13'
- implementation 'org.apache.xmlgraphics:batik-ext:1.13'
- implementation 'org.apache.xmlgraphics:batik-gvt:1.13'
- implementation 'org.apache.xmlgraphics:batik-parser:1.13'
- implementation 'org.apache.xmlgraphics:batik-script:1.13'
- implementation 'org.apache.xmlgraphics:batik-svg-dom:1.13'
- implementation 'org.apache.xmlgraphics:batik-svggen:1.13'
- implementation 'org.apache.xmlgraphics:batik-transcoder:1.13'
- implementation 'org.apache.xmlgraphics:batik-util:1.13'
- implementation 'org.apache.xmlgraphics:batik-xml:1.13'
+ implementation "org.apache.xmlgraphics:batik-anim:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-awt-util:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-bridge:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-css:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-dom:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-ext:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-gvt:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-parser:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-script:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-svg-dom:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-svggen:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-transcoder:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-util:${v_batik}"
+ implementation "org.apache.xmlgraphics:batik-xml:${v_batik}"
// Spelling, TeX
}
- testImplementation('org.junit.jupiter:junit-jupiter-api:5.4.2')
- testRuntime('org.junit.jupiter:junit-jupiter-engine:5.4.2')
+ testImplementation "org.junit.jupiter:junit-jupiter-api:${v_junit}"
+ testRuntime "org.junit.jupiter:junit-jupiter-engine:${v_junit}"
}
libs/jmathtex/jmathtex.jar
Binary files differ
src/main/java/com/keenwrite/Constants.java
/**
- * Default starting delimiter for definition variables.
+ * Default starting delimiter for definition variables. This value must
+ * not overlap math delimiters, so do not use $ tokens as the first
+ * delimiter.
*/
- public static final String DEF_DELIM_BEGAN_DEFAULT = "${";
+ public static final String DEF_DELIM_BEGAN_DEFAULT = "{{";
/**
* Default ending delimiter for definition variables.
*/
- public static final String DEF_DELIM_ENDED_DEFAULT = "}";
+ public static final String DEF_DELIM_ENDED_DEFAULT = "}}";
/**
*/
public static final float FONT_SIZE_EDITOR = 12f;
+
+ /**
+ * Default identifier to use for synchronized scrolling.
+ */
+ public static String CARET_ID = "caret";
/**
src/main/java/com/keenwrite/FileEditorTab.java
import com.keenwrite.editors.EditorPane;
import com.keenwrite.editors.markdown.MarkdownEditorPane;
-import com.keenwrite.service.events.Notification;
-import com.keenwrite.service.events.Notifier;
-import javafx.beans.binding.Bindings;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanWrapper;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.beans.value.ChangeListener;
-import javafx.event.Event;
-import javafx.event.EventHandler;
-import javafx.event.EventType;
-import javafx.scene.Scene;
-import javafx.scene.control.Tab;
-import javafx.scene.control.Tooltip;
-import javafx.scene.text.Text;
-import javafx.stage.Window;
-import org.fxmisc.flowless.VirtualizedScrollPane;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.undo.UndoManager;
-import org.jetbrains.annotations.NotNull;
-import org.mozilla.universalchardet.UniversalDetector;
-
-import java.io.File;
-import java.nio.charset.Charset;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static com.keenwrite.StatusBarNotifier.getNotifier;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Locale.ENGLISH;
-import static javafx.application.Platform.runLater;
-
-/**
- * Editor for a single file.
- */
-public final class FileEditorTab extends Tab {
-
- private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane();
-
- private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
- private final BooleanProperty canUndo = new SimpleBooleanProperty();
- private final BooleanProperty canRedo = new SimpleBooleanProperty();
-
- /**
- * Character encoding used by the file (or default encoding if none found).
- */
- private Charset mEncoding = UTF_8;
-
- /**
- * File to load into the editor.
- */
- private Path mPath;
-
- public FileEditorTab( final Path path ) {
- setPath( path );
-
- mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
-
- setOnSelectionChanged( e -> {
- if( isSelected() ) {
- runLater( this::activated );
- requestFocus();
- }
- } );
- }
-
- private void updateTab() {
- setText( getTabTitle() );
- setGraphic( getModifiedMark() );
- setTooltip( getTabTooltip() );
- }
-
- /**
- * Returns the base filename (without the directory names).
- *
- * @return The untitled text if the path hasn't been set.
- */
- private String getTabTitle() {
- return getPath().getFileName().toString();
- }
-
- /**
- * Returns the full filename represented by the path.
- *
- * @return The untitled text if the path hasn't been set.
- */
- private Tooltip getTabTooltip() {
- final Path filePath = getPath();
- return new Tooltip( filePath == null ? "" : filePath.toString() );
- }
-
- /**
- * Returns a marker to indicate whether the file has been modified.
- *
- * @return "*" when the file has changed; otherwise null.
- */
- private Text getModifiedMark() {
- return isModified() ? new Text( "*" ) : null;
- }
-
- /**
- * Called when the user switches tab.
- */
- private void activated() {
- // Tab is closed or no longer active.
- if( getTabPane() == null || !isSelected() ) {
- return;
- }
-
- // If the tab is devoid of content, load it.
- if( getContent() == null ) {
- readFile();
- initLayout();
- initUndoManager();
- }
- }
-
- private void initLayout() {
- setContent( getScrollPane() );
- }
-
- /**
- * Tracks undo requests, but can only be called <em>after</em> load.
- */
- private void initUndoManager() {
- final UndoManager<?> undoManager = getUndoManager();
- undoManager.forgetHistory();
-
- // Bind the editor undo manager to the properties.
- mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
- canUndo.bind( undoManager.undoAvailableProperty() );
- canRedo.bind( undoManager.redoAvailableProperty() );
- }
-
- private void requestFocus() {
- getEditorPane().requestFocus();
- }
-
- /**
- * Searches from the caret position forward for the given string.
- *
- * @param needle The text string to match.
- */
- public void searchNext( final String needle ) {
- final String haystack = getEditorText();
- int index = haystack.indexOf( needle, getCaretPosition() );
-
- // Wrap around.
- if( index == -1 ) {
- index = haystack.indexOf( needle );
- }
-
- if( index >= 0 ) {
- setCaretPosition( index );
- getEditor().selectRange( index, index + needle.length() );
- }
- }
-
- /**
- * Gets a reference to the scroll pane that houses the editor.
- *
- * @return The editor's scroll pane, containing a vertical scrollbar.
- */
- public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
- return getEditorPane().getScrollPane();
- }
-
- /**
- * Returns the index into the text where the caret blinks happily away.
- *
- * @return A number from 0 to the editor's document text length.
- */
- public int getCaretPosition() {
- return getEditor().getCaretPosition();
- }
-
- /**
- * Moves the caret to a given offset.
- *
- * @param offset The new caret offset.
- */
- private void setCaretPosition( final int offset ) {
- getEditor().moveTo( offset );
- getEditor().requestFollowCaret();
- }
-
- /**
- * Returns the text area associated with this tab.
- *
- * @return A text editor.
- */
- private StyleClassedTextArea getEditor() {
- return getEditorPane().getEditor();
- }
-
- /**
- * Returns true if the given path exactly matches this tab's path.
- *
- * @param check The path to compare against.
- * @return true The paths are the same.
- */
- public boolean isPath( final Path check ) {
- final Path filePath = getPath();
-
- return filePath != null && filePath.equals( check );
- }
-
- /**
- * Reads the entire file contents from the path associated with this tab.
- */
- private void readFile() {
- final Path path = getPath();
- final File file = path.toFile();
-
- try {
- if( file.exists() ) {
- if( file.canWrite() && file.canRead() ) {
- final EditorPane pane = getEditorPane();
- pane.setText( asString( Files.readAllBytes( path ) ) );
- pane.scrollToTop();
- }
- else {
- final String msg = get( "FileEditor.loadFailed.reason.permissions" );
- clue( "FileEditor.loadFailed.message", file.toString(), msg );
- }
- }
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- /**
- * Saves the entire file contents from the path associated with this tab.
- *
- * @return true The file has been saved.
- */
- public boolean save() {
- try {
- final EditorPane editor = getEditorPane();
- Files.write( getPath(), asBytes( editor.getText() ) );
- editor.getUndoManager().mark();
- return true;
- } catch( final Exception ex ) {
- return popupAlert(
- "FileEditor.saveFailed.title",
- "FileEditor.saveFailed.message",
- ex
- );
- }
- }
-
- /**
- * Creates an alert dialog and waits for it to close.
- *
- * @param titleKey Resource bundle key for the alert dialog title.
- * @param messageKey Resource bundle key for the alert dialog message.
- * @param e The unexpected happening.
- * @return false
- */
- @SuppressWarnings("SameParameterValue")
- private boolean popupAlert(
- final String titleKey, final String messageKey, final Exception e ) {
- final Notifier service = getNotifier();
- final Path filePath = getPath();
-
- final Notification message = service.createNotification(
- get( titleKey ),
- get( messageKey ),
- filePath == null ? "" : filePath,
- e.getMessage()
- );
-
- try {
- service.createError( getWindow(), message ).showAndWait();
- } catch( final Exception ex ) {
- clue( ex );
- }
-
- return false;
- }
-
- private Window getWindow() {
- final Scene scene = getEditorPane().getScene();
-
- if( scene == null ) {
- throw new UnsupportedOperationException( "No scene window available" );
- }
-
- return scene.getWindow();
- }
-
- /**
- * Returns a best guess at the file encoding. If the encoding could not be
- * detected, this will return the default charset for the JVM.
- *
- * @param bytes The bytes to perform character encoding detection.
- * @return The character encoding.
- */
- private Charset detectEncoding( final byte[] bytes ) {
- final var detector = new UniversalDetector( null );
- detector.handleData( bytes, 0, bytes.length );
- detector.dataEnd();
-
- final String charset = detector.getDetectedCharset();
-
- return charset == null
- ? Charset.defaultCharset()
- : Charset.forName( charset.toUpperCase( ENGLISH ) );
- }
-
- /**
- * Converts the given string to an array of bytes using the encoding that was
- * originally detected (if any) and associated with this file.
- *
- * @param text The text to convert into the original file encoding.
- * @return A series of bytes ready for writing to a file.
- */
- private byte[] asBytes( final String text ) {
- return text.getBytes( getEncoding() );
- }
-
- /**
- * Converts the given bytes into a Java String. This will call setEncoding
- * with the encoding detected by the CharsetDetector.
- *
- * @param text The text of unknown character encoding.
- * @return The text, in its auto-detected encoding, as a String.
- */
- private String asString( final byte[] text ) {
- setEncoding( detectEncoding( text ) );
- return new String( text, getEncoding() );
- }
-
- /**
- * Returns the path to the file being edited in this tab.
- *
- * @return A non-null instance.
- */
- public Path getPath() {
- return mPath;
- }
-
- /**
- * Sets the path to a file for editing and then updates the tab with the
- * file contents.
- *
- * @param path A non-null instance.
- */
- public void setPath( final Path path ) {
- assert path != null;
- mPath = path;
-
- updateTab();
- }
-
- public boolean isModified() {
- return mModified.get();
- }
-
- ReadOnlyBooleanProperty modifiedProperty() {
- return mModified.getReadOnlyProperty();
- }
-
- BooleanProperty canUndoProperty() {
- return this.canUndo;
- }
-
- BooleanProperty canRedoProperty() {
- return this.canRedo;
- }
-
- private UndoManager<?> getUndoManager() {
- return getEditorPane().getUndoManager();
- }
-
- /**
- * Forwards to the editor pane's listeners for text change events.
- *
- * @param listener The listener to notify when the text changes.
- */
- public void addTextChangeListener( final ChangeListener<String> listener ) {
- getEditorPane().addTextChangeListener( listener );
- }
-
- /**
- * Forwards to the editor pane's listeners for caret change events.
- *
- * @param listener Notified when the caret position changes.
- */
- public void addCaretPositionListener(
- final ChangeListener<? super Integer> listener ) {
- getEditorPane().addCaretPositionListener( listener );
- }
-
- /**
- * Forwards to the editor pane's listeners for paragraph index change events.
- *
- * @param listener Notified when the caret's paragraph index changes.
- */
- public void addCaretParagraphListener(
- final ChangeListener<? super Integer> listener ) {
- getEditorPane().addCaretParagraphListener( listener );
+import com.keenwrite.processors.Processor;
+import com.keenwrite.processors.markdown.CaretPosition;
+import com.keenwrite.service.events.Notification;
+import com.keenwrite.service.events.Notifier;
+import javafx.beans.binding.Bindings;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanWrapper;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.event.EventType;
+import javafx.scene.Scene;
+import javafx.scene.control.Tab;
+import javafx.scene.control.Tooltip;
+import javafx.scene.text.Text;
+import javafx.stage.Window;
+import org.fxmisc.flowless.VirtualizedScrollPane;
+import org.fxmisc.richtext.StyleClassedTextArea;
+import org.fxmisc.undo.UndoManager;
+import org.jetbrains.annotations.NotNull;
+import org.mozilla.universalchardet.UniversalDetector;
+
+import java.io.File;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.StatusBarNotifier.getNotifier;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Locale.ENGLISH;
+import static javafx.application.Platform.runLater;
+
+/**
+ * Editor for a single file.
+ */
+public final class FileEditorTab extends Tab {
+
+ private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane();
+
+ private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
+ private final BooleanProperty canUndo = new SimpleBooleanProperty();
+ private final BooleanProperty canRedo = new SimpleBooleanProperty();
+
+ /**
+ * Character encoding used by the file (or default encoding if none found).
+ */
+ private Charset mEncoding = UTF_8;
+
+ /**
+ * File to load into the editor.
+ */
+ private Path mPath;
+
+ /**
+ * Dynamically updated position of the caret within the text editor.
+ */
+ private final CaretPosition mCaretPosition;
+
+ public FileEditorTab( final Path path ) {
+ setPath( path );
+
+ mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
+
+ setOnSelectionChanged( e -> {
+ if( isSelected() ) {
+ runLater( this::activated );
+ requestFocus();
+ }
+ } );
+
+ mCaretPosition = createCaretPosition( getEditor() );
+ }
+
+ private CaretPosition createCaretPosition(
+ final StyleClassedTextArea editor ) {
+ final var propParaIndex = editor.currentParagraphProperty();
+ final var propParagraphs = editor.getParagraphs();
+ final var propParaOffset = editor.caretColumnProperty();
+ final var propTextOffset = editor.caretPositionProperty();
+
+ return CaretPosition
+ .builder()
+ .with( CaretPosition.Mutator::setParagraph, propParaIndex )
+ .with( CaretPosition.Mutator::setParagraphs, propParagraphs )
+ .with( CaretPosition.Mutator::setParaOffset, propParaOffset )
+ .with( CaretPosition.Mutator::setTextOffset, propTextOffset )
+ .build();
+ }
+
+ private void updateTab() {
+ setText( getTabTitle() );
+ setGraphic( getModifiedMark() );
+ setTooltip( getTabTooltip() );
+ }
+
+ /**
+ * Returns the base filename (without the directory names).
+ *
+ * @return The untitled text if the path hasn't been set.
+ */
+ private String getTabTitle() {
+ return getPath().getFileName().toString();
+ }
+
+ /**
+ * Returns the full filename represented by the path.
+ *
+ * @return The untitled text if the path hasn't been set.
+ */
+ private Tooltip getTabTooltip() {
+ final Path filePath = getPath();
+ return new Tooltip( filePath == null ? "" : filePath.toString() );
+ }
+
+ /**
+ * Returns a marker to indicate whether the file has been modified.
+ *
+ * @return "*" when the file has changed; otherwise null.
+ */
+ private Text getModifiedMark() {
+ return isModified() ? new Text( "*" ) : null;
+ }
+
+ /**
+ * Called when the user switches tab.
+ */
+ private void activated() {
+ // Tab is closed or no longer active.
+ if( getTabPane() == null || !isSelected() ) {
+ return;
+ }
+
+ // If the tab is devoid of content, load it.
+ if( getContent() == null ) {
+ readFile();
+ initLayout();
+ initUndoManager();
+ }
+ }
+
+ private void initLayout() {
+ setContent( getScrollPane() );
+ }
+
+ /**
+ * Tracks undo requests, but can only be called <em>after</em> load.
+ */
+ private void initUndoManager() {
+ final UndoManager<?> undoManager = getUndoManager();
+ undoManager.forgetHistory();
+
+ // Bind the editor undo manager to the properties.
+ mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
+ canUndo.bind( undoManager.undoAvailableProperty() );
+ canRedo.bind( undoManager.redoAvailableProperty() );
+ }
+
+ private void requestFocus() {
+ getEditorPane().requestFocus();
+ }
+
+ /**
+ * Searches from the caret position forward for the given string.
+ *
+ * @param needle The text string to match.
+ */
+ public void searchNext( final String needle ) {
+ final String haystack = getEditorText();
+ int index = haystack.indexOf( needle, getCaretTextOffset() );
+
+ // Wrap around.
+ if( index == -1 ) {
+ index = haystack.indexOf( needle );
+ }
+
+ if( index >= 0 ) {
+ setCaretTextOffset( index );
+ getEditor().selectRange( index, index + needle.length() );
+ }
+ }
+
+ /**
+ * Gets a reference to the scroll pane that houses the editor.
+ *
+ * @return The editor's scroll pane, containing a vertical scrollbar.
+ */
+ public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
+ return getEditorPane().getScrollPane();
+ }
+
+ /**
+ * Returns an instance of {@link CaretPosition} that contains information
+ * about the caret, including the offset into the text, the paragraph into
+ * the text, maximum number of paragraphs, and more. This allows the main
+ * application and the {@link Processor} instances to get the current
+ * caret position.
+ *
+ * @return The current values for the caret's position within the editor.
+ */
+ public CaretPosition getCaretPosition() {
+ return mCaretPosition;
+ }
+
+ /**
+ * Returns the index into the text where the caret blinks happily away.
+ *
+ * @return A number from 0 to the editor's document text length.
+ */
+ private int getCaretTextOffset() {
+ return getEditor().getCaretPosition();
+ }
+
+ /**
+ * Moves the caret to a given offset.
+ *
+ * @param offset The new caret offset.
+ */
+ private void setCaretTextOffset( final int offset ) {
+ getEditor().moveTo( offset );
+ getEditor().requestFollowCaret();
+ }
+
+ /**
+ * Returns the text area associated with this tab.
+ *
+ * @return A text editor.
+ */
+ private StyleClassedTextArea getEditor() {
+ return getEditorPane().getEditor();
+ }
+
+ /**
+ * Returns true if the given path exactly matches this tab's path.
+ *
+ * @param check The path to compare against.
+ * @return true The paths are the same.
+ */
+ public boolean isPath( final Path check ) {
+ final Path filePath = getPath();
+
+ return filePath != null && filePath.equals( check );
+ }
+
+ /**
+ * Reads the entire file contents from the path associated with this tab.
+ */
+ private void readFile() {
+ final Path path = getPath();
+ final File file = path.toFile();
+
+ try {
+ if( file.exists() ) {
+ if( file.canWrite() && file.canRead() ) {
+ final EditorPane pane = getEditorPane();
+ pane.setText( asString( Files.readAllBytes( path ) ) );
+ pane.scrollToTop();
+ }
+ else {
+ final String msg = get( "FileEditor.loadFailed.reason.permissions" );
+ clue( "FileEditor.loadFailed.message", file.toString(), msg );
+ }
+ }
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ /**
+ * Saves the entire file contents from the path associated with this tab.
+ *
+ * @return true The file has been saved.
+ */
+ public boolean save() {
+ try {
+ final EditorPane editor = getEditorPane();
+ Files.write( getPath(), asBytes( editor.getText() ) );
+ editor.getUndoManager().mark();
+ return true;
+ } catch( final Exception ex ) {
+ return popupAlert(
+ "FileEditor.saveFailed.title",
+ "FileEditor.saveFailed.message",
+ ex
+ );
+ }
+ }
+
+ /**
+ * Creates an alert dialog and waits for it to close.
+ *
+ * @param titleKey Resource bundle key for the alert dialog title.
+ * @param messageKey Resource bundle key for the alert dialog message.
+ * @param e The unexpected happening.
+ * @return false
+ */
+ @SuppressWarnings("SameParameterValue")
+ private boolean popupAlert(
+ final String titleKey, final String messageKey, final Exception e ) {
+ final Notifier service = getNotifier();
+ final Path filePath = getPath();
+
+ final Notification message = service.createNotification(
+ get( titleKey ),
+ get( messageKey ),
+ filePath == null ? "" : filePath,
+ e.getMessage()
+ );
+
+ try {
+ service.createError( getWindow(), message ).showAndWait();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+
+ return false;
+ }
+
+ private Window getWindow() {
+ final Scene scene = getEditorPane().getScene();
+
+ if( scene == null ) {
+ throw new UnsupportedOperationException( "No scene window available" );
+ }
+
+ return scene.getWindow();
+ }
+
+ /**
+ * Returns a best guess at the file encoding. If the encoding could not be
+ * detected, this will return the default charset for the JVM.
+ *
+ * @param bytes The bytes to perform character encoding detection.
+ * @return The character encoding.
+ */
+ private Charset detectEncoding( final byte[] bytes ) {
+ final var detector = new UniversalDetector( null );
+ detector.handleData( bytes, 0, bytes.length );
+ detector.dataEnd();
+
+ final String charset = detector.getDetectedCharset();
+
+ return charset == null
+ ? Charset.defaultCharset()
+ : Charset.forName( charset.toUpperCase( ENGLISH ) );
+ }
+
+ /**
+ * Converts the given string to an array of bytes using the encoding that was
+ * originally detected (if any) and associated with this file.
+ *
+ * @param text The text to convert into the original file encoding.
+ * @return A series of bytes ready for writing to a file.
+ */
+ private byte[] asBytes( final String text ) {
+ return text.getBytes( getEncoding() );
+ }
+
+ /**
+ * Converts the given bytes into a Java String. This will call setEncoding
+ * with the encoding detected by the CharsetDetector.
+ *
+ * @param text The text of unknown character encoding.
+ * @return The text, in its auto-detected encoding, as a String.
+ */
+ private String asString( final byte[] text ) {
+ setEncoding( detectEncoding( text ) );
+ return new String( text, getEncoding() );
+ }
+
+ /**
+ * Returns the path to the file being edited in this tab.
+ *
+ * @return A non-null instance.
+ */
+ public Path getPath() {
+ return mPath;
+ }
+
+ /**
+ * Sets the path to a file for editing and then updates the tab with the
+ * file contents.
+ *
+ * @param path A non-null instance.
+ */
+ public void setPath( final Path path ) {
+ assert path != null;
+ mPath = path;
+
+ updateTab();
+ }
+
+ public boolean isModified() {
+ return mModified.get();
+ }
+
+ ReadOnlyBooleanProperty modifiedProperty() {
+ return mModified.getReadOnlyProperty();
+ }
+
+ BooleanProperty canUndoProperty() {
+ return this.canUndo;
+ }
+
+ BooleanProperty canRedoProperty() {
+ return this.canRedo;
+ }
+
+ private UndoManager<?> getUndoManager() {
+ return getEditorPane().getUndoManager();
+ }
+
+ /**
+ * Forwards to the editor pane's listeners for text change events.
+ *
+ * @param listener The listener to notify when the text changes.
+ */
+ public void addTextChangeListener( final ChangeListener<String> listener ) {
+ getEditorPane().addTextChangeListener( listener );
+ }
+
+ /**
+ * Forwards to the editor pane's listeners for caret change events.
+ *
+ * @param listener Notified when the caret position changes.
+ */
+ public void addCaretPositionListener(
+ final ChangeListener<? super Integer> listener ) {
+ getEditorPane().addCaretPositionListener( listener );
}
src/main/java/com/keenwrite/FileEditorTabPane.java
new ReadOnlyBooleanWrapper();
private final ChangeListener<Integer> mCaretPositionListener;
- private final ChangeListener<Integer> mCaretParagraphListener;
/**
* Constructs a new file editor tab pane.
*
* @param caretPositionListener Listens for changes to caret position so
* that the status bar can update.
- * @param caretParagraphListener Listens for changes to the caret's paragraph
- * so that scrolling may occur.
*/
public FileEditorTabPane(
- final ChangeListener<Integer> caretPositionListener,
- final ChangeListener<Integer> caretParagraphListener ) {
+ final ChangeListener<Integer> caretPositionListener ) {
final ObservableList<Tab> tabs = getTabs();
mCaretPositionListener = caretPositionListener;
- mCaretParagraphListener = caretParagraphListener;
}
tab.addCaretPositionListener( mCaretPositionListener );
- tab.addCaretParagraphListener( mCaretParagraphListener );
return tab;
src/main/java/com/keenwrite/MainWindow.java
import com.keenwrite.definition.yaml.YamlDefinitionSource;
import com.keenwrite.editors.DefinitionNameInjector;
-import com.keenwrite.editors.EditorPane;
-import com.keenwrite.editors.markdown.MarkdownEditorPane;
-import com.keenwrite.exceptions.MissingFileException;
-import com.keenwrite.preferences.UserPreferences;
-import com.keenwrite.preview.HTMLPreviewPane;
-import com.keenwrite.processors.Processor;
-import com.keenwrite.processors.ProcessorContext;
-import com.keenwrite.processors.ProcessorFactory;
-import com.keenwrite.processors.markdown.MarkdownProcessor;
-import com.keenwrite.service.Options;
-import com.keenwrite.service.Snitch;
-import com.keenwrite.spelling.api.SpellCheckListener;
-import com.keenwrite.spelling.api.SpellChecker;
-import com.keenwrite.spelling.impl.PermissiveSpeller;
-import com.keenwrite.spelling.impl.SymSpellSpeller;
-import com.keenwrite.util.Action;
-import com.keenwrite.util.ActionBuilder;
-import com.keenwrite.util.ActionUtils;
-import com.keenwrite.util.SeparatorAction;
-import com.vladsch.flexmark.parser.Parser;
-import com.vladsch.flexmark.util.ast.NodeVisitor;
-import com.vladsch.flexmark.util.ast.VisitHandler;
-import javafx.beans.binding.Bindings;
-import javafx.beans.binding.BooleanBinding;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.beans.value.ChangeListener;
-import javafx.beans.value.ObservableBooleanValue;
-import javafx.beans.value.ObservableValue;
-import javafx.collections.ListChangeListener.Change;
-import javafx.collections.ObservableList;
-import javafx.event.Event;
-import javafx.event.EventHandler;
-import javafx.geometry.Pos;
-import javafx.scene.Node;
-import javafx.scene.Scene;
-import javafx.scene.control.*;
-import javafx.scene.image.ImageView;
-import javafx.scene.input.KeyEvent;
-import javafx.scene.layout.BorderPane;
-import javafx.scene.layout.VBox;
-import javafx.scene.text.Text;
-import javafx.stage.FileChooser;
-import javafx.stage.Window;
-import javafx.stage.WindowEvent;
-import javafx.util.Duration;
-import org.apache.commons.lang3.SystemUtils;
-import org.controlsfx.control.StatusBar;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.richtext.model.StyleSpansBuilder;
-import org.reactfx.value.Val;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.nio.file.Path;
-import java.util.*;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import java.util.prefs.Preferences;
-import java.util.stream.Collectors;
-
-import static com.keenwrite.Bootstrap.APP_TITLE;
-import static com.keenwrite.Constants.*;
-import static com.keenwrite.ExportFormat.*;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static com.keenwrite.processors.ProcessorFactory.processChain;
-import static com.keenwrite.util.StageState.*;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.nio.file.Files.writeString;
-import static java.util.Collections.emptyList;
-import static java.util.Collections.singleton;
-import static javafx.application.Platform.runLater;
-import static javafx.event.Event.fireEvent;
-import static javafx.scene.control.Alert.AlertType.INFORMATION;
-import static javafx.scene.input.KeyCode.ENTER;
-import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
-import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
-
-/**
- * Main window containing a tab pane in the center for file editors.
- */
-public class MainWindow implements Observer {
- /**
- * The {@code OPTIONS} variable must be declared before all other variables
- * to prevent subsequent initializations from failing due to missing user
- * preferences.
- */
- private static final Options sOptions = Services.load( Options.class );
- private static final Snitch SNITCH = Services.load( Snitch.class );
-
- private final Scene mScene;
- private final StatusBar mStatusBar;
- private final Text mLineNumberText;
- private final TextField mFindTextField;
- private final SpellChecker mSpellChecker;
-
- private final Object mMutex = new Object();
-
- /**
- * Prevents re-instantiation of processing classes.
- */
- private final Map<FileEditorTab, Processor<String>> mProcessors =
- new HashMap<>();
-
- private final Map<String, String> mResolvedMap =
- new HashMap<>( DEFAULT_MAP_SIZE );
-
- private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
- event -> rerender();
-
- /**
- * Called when the definition data is changed.
- */
- private final EventHandler<TreeItem.TreeModificationEvent<Event>>
- mTreeHandler = event -> {
- exportDefinitions( getDefinitionPath() );
- interpolateResolvedMap();
- rerender();
- };
-
- /**
- * Called to inject the selected item when the user presses ENTER in the
- * definition pane.
- */
- private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
- event -> {
- if( event.getCode() == ENTER ) {
- getDefinitionNameInjector().injectSelectedItem();
- }
- };
-
- private final ChangeListener<Integer> mCaretPositionListener =
- ( observable, oldPosition, newPosition ) -> {
- final FileEditorTab tab = getActiveFileEditorTab();
- final EditorPane pane = tab.getEditorPane();
- final StyleClassedTextArea editor = pane.getEditor();
-
- getLineNumberText().setText(
- get( STATUS_BAR_LINE,
- editor.getCurrentParagraph() + 1,
- editor.getParagraphs().size(),
- editor.getCaretPosition()
- )
- );
- };
-
- private final ChangeListener<Integer> mCaretParagraphListener =
- ( observable, oldIndex, newIndex ) ->
- scrollToParagraph( newIndex, true );
-
- private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
- private final DefinitionPane mDefinitionPane = createDefinitionPane();
- private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
- private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
- mCaretPositionListener,
- mCaretParagraphListener );
-
- /**
- * Listens on the definition pane for double-click events.
- */
- private final DefinitionNameInjector mDefinitionNameInjector
- = new DefinitionNameInjector( mDefinitionPane );
-
- public MainWindow() {
- mStatusBar = createStatusBar();
- mLineNumberText = createLineNumberText();
- mFindTextField = createFindTextField();
- mScene = createScene();
- mSpellChecker = createSpellChecker();
-
- // Add the close request listener before the window is shown.
- initLayout();
- StatusBarNotifier.setStatusBar( mStatusBar );
- }
-
- /**
- * Called after the stage is shown.
- */
- public void init() {
- initFindInput();
- initSnitch();
- initDefinitionListener();
- initTabAddedListener();
- initTabChangedListener();
- initPreferences();
- initVariableNameInjector();
- }
-
- private void initLayout() {
- final var scene = getScene();
-
- scene.getStylesheets().add( STYLESHEET_SCENE );
- scene.windowProperty().addListener(
- ( unused, oldWindow, newWindow ) ->
- newWindow.setOnCloseRequest(
- e -> {
- if( !getFileEditorPane().closeAllEditors() ) {
- e.consume();
- }
- }
- )
- );
- }
-
- /**
- * Initialize the find input text field to listen on F3, ENTER, and
- * ESCAPE key presses.
- */
- private void initFindInput() {
- final TextField input = getFindTextField();
-
- input.setOnKeyPressed( ( KeyEvent event ) -> {
- switch( event.getCode() ) {
- case F3:
- case ENTER:
- editFindNext();
- break;
- case F:
- if( !event.isControlDown() ) {
- break;
- }
- case ESCAPE:
- getStatusBar().setGraphic( null );
- getActiveFileEditorTab().getEditorPane().requestFocus();
- break;
- }
- } );
-
- // Remove when the input field loses focus.
- input.focusedProperty().addListener(
- ( focused, oldFocus, newFocus ) -> {
- if( !newFocus ) {
- getStatusBar().setGraphic( null );
- }
- }
- );
- }
-
- /**
- * Watch for changes to external files. In particular, this awaits
- * modifications to any XSL files associated with XML files being edited.
- * When
- * an XSL file is modified (external to the application), the snitch's ears
- * perk up and the file is reloaded. This keeps the XSL transformation up to
- * date with what's on the file system.
- */
- private void initSnitch() {
- SNITCH.addObserver( this );
- }
-
- /**
- * Listen for {@link FileEditorTabPane} to receive open definition file
- * event.
- */
- private void initDefinitionListener() {
- getFileEditorPane().onOpenDefinitionFileProperty().addListener(
- ( final ObservableValue<? extends Path> file,
- final Path oldPath, final Path newPath ) -> {
- openDefinitions( newPath );
- rerender();
- }
- );
- }
-
- /**
- * Re-instantiates all processors then re-renders the active tab. This
- * will refresh the resolved map, force R to re-initialize, and brute-force
- * XSLT file reloads.
- */
- private void rerender() {
- runLater(
- () -> {
- resetProcessors();
- renderActiveTab();
- }
- );
- }
-
- /**
- * When tabs are added, hook the various change listeners onto the new
- * tab sothat the preview pane refreshes as necessary.
- */
- private void initTabAddedListener() {
- final FileEditorTabPane editorPane = getFileEditorPane();
-
- // Make sure the text processor kicks off when new files are opened.
- final ObservableList<Tab> tabs = editorPane.getTabs();
-
- // Update the preview pane on tab changes.
- tabs.addListener(
- ( final Change<? extends Tab> change ) -> {
- while( change.next() ) {
- if( change.wasAdded() ) {
- // Multiple tabs can be added simultaneously.
- for( final Tab newTab : change.getAddedSubList() ) {
- final FileEditorTab tab = (FileEditorTab) newTab;
-
- initTextChangeListener( tab );
- initScrollEventListener( tab );
- initSpellCheckListener( tab );
-// initSyntaxListener( tab );
- }
- }
- }
- }
- );
- }
-
- private void initTextChangeListener( final FileEditorTab tab ) {
- tab.addTextChangeListener(
- ( __, ov, nv ) -> {
- process( tab );
- scrollToParagraph( getCurrentParagraphIndex() );
- }
- );
- }
-
- private void initScrollEventListener( final FileEditorTab tab ) {
- final var scrollPane = tab.getScrollPane();
- final var scrollBar = getPreviewPane().getVerticalScrollBar();
-
- addShowListener( scrollPane, ( __ ) -> {
- final var handler = new ScrollEventHandler( scrollPane, scrollBar );
- handler.enabledProperty().bind( tab.selectedProperty() );
- } );
- }
-
- /**
- * Listen for changes to the any particular paragraph and perform a quick
- * spell check upon it. The style classes in the editor will be changed to
- * mark any spelling mistakes in the paragraph. The user may then interact
- * with any misspelled word (i.e., any piece of text that is marked) to
- * revise the spelling.
- *
- * @param tab The tab to spellcheck.
- */
- private void initSpellCheckListener( final FileEditorTab tab ) {
- final var editor = tab.getEditorPane().getEditor();
-
- // When the editor first appears, run a full spell check. This allows
- // spell checking while typing to be restricted to the active paragraph,
- // which is usually substantially smaller than the whole document.
- addShowListener(
- editor, ( __ ) -> spellcheck( editor, editor.getText() )
- );
-
- // Use the plain text changes so that notifications of style changes
- // are suppressed. Checking against the identity ensures that only
- // new text additions or deletions trigger proofreading.
- editor.plainTextChanges()
- .filter( p -> !p.isIdentity() ).subscribe( change -> {
-
- // Only perform a spell check on the current paragraph. The
- // entire document is processed once, when opened.
- final var offset = change.getPosition();
- final var position = editor.offsetToPosition( offset, Forward );
- final var paraId = position.getMajor();
- final var paragraph = editor.getParagraph( paraId );
- final var text = paragraph.getText();
-
- // Ensure that styles aren't doubled-up.
- editor.clearStyle( paraId );
-
- spellcheck( editor, text, paraId );
- } );
- }
-
- /**
- * Listen for new tab selection events.
- */
- private void initTabChangedListener() {
- final FileEditorTabPane editorPane = getFileEditorPane();
-
- // Update the preview pane changing tabs.
- editorPane.addTabSelectionListener(
- ( tabPane, oldTab, newTab ) -> {
- if( newTab == null ) {
- // Clear the preview pane when closing an editor. When the last
- // tab is closed, this ensures that the preview pane is empty.
- getPreviewPane().clear();
- }
- else {
- final var tab = (FileEditorTab) newTab;
- updateVariableNameInjector( tab );
- process( tab );
- }
- }
- );
- }
-
- /**
- * Reloads the preferences from the previous session.
- */
- private void initPreferences() {
- initDefinitionPane();
- getFileEditorPane().initPreferences();
- getUserPreferences().addSaveEventHandler( mRPreferencesListener );
- }
-
- private void initVariableNameInjector() {
- updateVariableNameInjector( getActiveFileEditorTab() );
- }
-
- /**
- * Calls the listener when the given node is shown for the first time. The
- * visible property is not the same as the initial showing event; visibility
- * can be triggered numerous times (such as going off screen).
- * <p>
- * This is called, for example, before the drag handler can be attached,
- * because the scrollbar for the text editor pane must be visible.
- * </p>
- *
- * @param node The node to watch for showing.
- * @param consumer The consumer to invoke when the event fires.
- */
- private void addShowListener(
- final Node node, final Consumer<Void> consumer ) {
- final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
- runLater( () -> {
- if( newShow != null && newShow ) {
- try {
- consumer.accept( null );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
- } );
-
- Val.flatMap( node.sceneProperty(), Scene::windowProperty )
- .flatMap( Window::showingProperty )
- .addListener( listener );
- }
-
- private void scrollToParagraph( final int id ) {
- scrollToParagraph( id, false );
- }
-
- /**
- * @param id The paragraph to scroll to, will be approximated if it doesn't
- * exist.
- * @param force {@code true} means to force scrolling immediately, which
- * should only be attempted when it is known that the document
- * has been fully rendered. Otherwise the internal map of ID
- * attributes will be incomplete and scrolling will flounder.
- */
- private void scrollToParagraph( final int id, final boolean force ) {
- synchronized( mMutex ) {
- final var previewPane = getPreviewPane();
- final var scrollPane = previewPane.getScrollPane();
- final int approxId = getActiveEditorPane().approximateParagraphId( id );
-
- if( force ) {
- previewPane.scrollTo( approxId );
- }
- else {
- previewPane.tryScrollTo( approxId );
- }
-
- scrollPane.repaint();
- }
- }
-
- private void updateVariableNameInjector( final FileEditorTab tab ) {
- getDefinitionNameInjector().addListener( tab );
- }
-
- /**
- * Called whenever the preview pane becomes out of sync with the file editor
- * tab. This can be called when the text changes, the caret paragraph
- * changes, or the file tab changes.
- *
- * @param tab The file editor tab that has been changed in some fashion.
- */
- private void process( final FileEditorTab tab ) {
- if( tab != null ) {
- getPreviewPane().setPath( tab.getPath() );
-
- final Processor<String> processor = getProcessors().computeIfAbsent(
- tab, p -> createProcessors( tab )
- );
-
- try {
- processChain( processor, tab.getEditorText() );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
- }
-
- private void renderActiveTab() {
- process( getActiveFileEditorTab() );
- }
-
- /**
- * Called when a definition source is opened.
- *
- * @param path Path to the definition source that was opened.
- */
- private void openDefinitions( final Path path ) {
- try {
- final var ds = createDefinitionSource( path );
- setDefinitionSource( ds );
-
- final var prefs = getUserPreferences();
- prefs.definitionPathProperty().setValue( path.toFile() );
- prefs.save();
-
- final var tooltipPath = new Tooltip( path.toString() );
- tooltipPath.setShowDelay( Duration.millis( 200 ) );
-
- final var pane = getDefinitionPane();
- pane.update( ds );
- pane.addTreeChangeHandler( mTreeHandler );
- pane.addKeyEventHandler( mDefinitionKeyHandler );
- pane.filenameProperty().setValue( path.getFileName().toString() );
- pane.setTooltip( tooltipPath );
-
- interpolateResolvedMap();
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private void exportDefinitions( final Path path ) {
- try {
- final var pane = getDefinitionPane();
- final var root = pane.getTreeView().getRoot();
- final var problemChild = pane.isTreeWellFormed();
-
- if( problemChild == null ) {
- getDefinitionSource().getTreeAdapter().export( root, path );
- }
- else {
- clue( "yaml.error.tree.form", problemChild.getValue() );
- }
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private void interpolateResolvedMap() {
- final var treeMap = getDefinitionPane().toMap();
- final var map = new HashMap<>( treeMap );
- MapInterpolator.interpolate( map );
-
- getResolvedMap().clear();
- getResolvedMap().putAll( map );
- }
-
- private void initDefinitionPane() {
- openDefinitions( getDefinitionPath() );
- }
-
- //---- File actions -------------------------------------------------------
-
- /**
- * Called when an {@link Observable} instance has changed. This is called
- * by both the {@link Snitch} service and the notify service. The @link
- * Snitch} service can be called for different file types, including
- * {@link DefinitionSource} instances.
- *
- * @param observable The observed instance.
- * @param value The noteworthy item.
- */
- @Override
- public void update( final Observable observable, final Object value ) {
- if( value instanceof Path && observable instanceof Snitch ) {
- updateSelectedTab();
- }
- }
-
- /**
- * Called when a file has been modified.
- */
- private void updateSelectedTab() {
- rerender();
- }
-
- /**
- * After resetting the processors, they will refresh anew to be up-to-date
- * with the files (text and definition) currently loaded into the editor.
- */
- private void resetProcessors() {
- getProcessors().clear();
- }
-
- //---- File actions -------------------------------------------------------
-
- private void fileNew() {
- getFileEditorPane().newEditor();
- }
-
- private void fileOpen() {
- getFileEditorPane().openFileDialog();
- }
-
- private void fileClose() {
- getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
- }
-
- /**
- * TODO: Upon closing, first remove the tab change listeners. (There's no
- * need to re-render each tab when all are being closed.)
- */
- private void fileCloseAll() {
- getFileEditorPane().closeAllEditors();
- }
-
- private void fileSave() {
- getFileEditorPane().saveEditor( getActiveFileEditorTab() );
- }
-
- private void fileSaveAs() {
- final FileEditorTab editor = getActiveFileEditorTab();
- getFileEditorPane().saveEditorAs( editor );
- getProcessors().remove( editor );
-
- try {
- process( editor );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private void fileSaveAll() {
- getFileEditorPane().saveAllEditors();
- }
-
- /**
- * Exports the contents of the current tab according to the given
- * {@link ExportFormat}.
- *
- * @param format Configures the {@link MarkdownProcessor} when exporting.
- */
- private void fileExport( final ExportFormat format ) {
- final var tab = getActiveFileEditorTab();
- final var context = createProcessorContext( tab, format );
- final var chain = ProcessorFactory.createProcessors( context );
- final var doc = tab.getEditorText();
- final var export = processChain( chain, doc );
-
- final var filename = format.toExportFilename( tab.getPath().toFile() );
- final var dir = getPreferences().get( "lastDirectory", null );
- final var lastDir = new File( dir == null ? "." : dir );
-
- final FileChooser chooser = new FileChooser();
- chooser.setTitle( get( "Dialog.file.choose.export.title" ) );
- chooser.setInitialFileName( filename.getName() );
- chooser.setInitialDirectory( lastDir );
-
- final File file = chooser.showSaveDialog( getWindow() );
-
- if( file != null ) {
- try {
- writeString( file.toPath(), export, UTF_8 );
- final var m = get( "Main.status.export.success", file.toString() );
- clue( m );
- } catch( final IOException e ) {
- clue( e );
- }
- }
- }
-
- private void fileExit() {
- final Window window = getWindow();
- fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
- }
-
- //---- Edit actions -------------------------------------------------------
-
- /**
- * Used to find text in the active file editor window.
- */
- private void editFind() {
- final TextField input = getFindTextField();
- getStatusBar().setGraphic( input );
- input.requestFocus();
- }
-
- public void editFindNext() {
- getActiveFileEditorTab().searchNext( getFindTextField().getText() );
- }
-
- public void editPreferences() {
- getUserPreferences().show();
- }
-
- //---- Insert actions -----------------------------------------------------
-
- /**
- * Delegates to the active editor to handle wrapping the current text
- * selection with leading and trailing strings.
- *
- * @param leading The string to put before the selection.
- * @param trailing The string to put after the selection.
- */
- private void insertMarkdown(
- final String leading, final String trailing ) {
- getActiveEditorPane().surroundSelection( leading, trailing );
- }
-
- private void insertMarkdown(
- final String leading, final String trailing, final String hint ) {
- getActiveEditorPane().surroundSelection( leading, trailing, hint );
- }
-
- //---- Help actions -------------------------------------------------------
-
- private void helpAbout() {
- final Alert alert = new Alert( INFORMATION );
- alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
- alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
- alert.setContentText( get( "Dialog.about.content" ) );
- alert.setGraphic( new ImageView( ICON_DIALOG ) );
- alert.initOwner( getWindow() );
-
- alert.showAndWait();
- }
-
- //---- Member creators ----------------------------------------------------
-
- private SpellChecker createSpellChecker() {
- try {
- final Collection<String> lexicon = readLexicon( "en.txt" );
- return SymSpellSpeller.forLexicon( lexicon );
- } catch( final Exception ex ) {
- clue( ex );
- return new PermissiveSpeller();
- }
- }
-
- /**
- * Creates processors suited to parsing and rendering different file types.
- *
- * @param tab The tab that is subjected to processing.
- * @return A processor suited to the file type specified by the tab's path.
- */
- private Processor<String> createProcessors( final FileEditorTab tab ) {
- final var context = createProcessorContext( tab );
- return ProcessorFactory.createProcessors( context );
- }
-
- private ProcessorContext createProcessorContext(
- final FileEditorTab tab, final ExportFormat format ) {
- final var pane = getPreviewPane();
- final var map = getResolvedMap();
- final var path = tab.getPath();
- return new ProcessorContext( pane, map, path, format );
- }
-
- private ProcessorContext createProcessorContext( final FileEditorTab tab ) {
- return createProcessorContext( tab, NONE );
- }
-
- private DefinitionPane createDefinitionPane() {
- return new DefinitionPane();
- }
-
- private HTMLPreviewPane createHTMLPreviewPane() {
- return new HTMLPreviewPane();
- }
-
- private DefinitionSource createDefaultDefinitionSource() {
- return new YamlDefinitionSource( getDefinitionPath() );
- }
-
- private DefinitionSource createDefinitionSource( final Path path ) {
- try {
- return createDefinitionFactory().createDefinitionSource( path );
- } catch( final Exception ex ) {
- clue( ex );
- return createDefaultDefinitionSource();
- }
- }
-
- private TextField createFindTextField() {
- return new TextField();
- }
-
- private DefinitionFactory createDefinitionFactory() {
- return new DefinitionFactory();
- }
-
- private StatusBar createStatusBar() {
- return new StatusBar();
- }
-
- private Scene createScene() {
- final SplitPane splitPane = new SplitPane(
- getDefinitionPane(),
- getFileEditorPane(),
- getPreviewPane() );
-
- splitPane.setDividerPositions(
- getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
- getFloat( K_PANE_SPLIT_EDITOR, .60f ),
- getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
-
- getDefinitionPane().prefHeightProperty()
- .bind( splitPane.heightProperty() );
-
- final BorderPane borderPane = new BorderPane();
- borderPane.setPrefSize( 1280, 800 );
- borderPane.setTop( createMenuBar() );
- borderPane.setBottom( getStatusBar() );
- borderPane.setCenter( splitPane );
-
- final VBox statusBar = new VBox();
- statusBar.setAlignment( Pos.BASELINE_CENTER );
- statusBar.getChildren().add( getLineNumberText() );
- getStatusBar().getRightItems().add( statusBar );
-
- // Force preview pane refresh on Windows.
- if( SystemUtils.IS_OS_WINDOWS ) {
- splitPane.getDividers().get( 1 ).positionProperty().addListener(
- ( l, oValue, nValue ) -> runLater(
- () -> getPreviewPane().getScrollPane().repaint()
- )
- );
- }
-
- return new Scene( borderPane );
- }
-
- private Text createLineNumberText() {
- return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
- }
-
- private Node createMenuBar() {
- final BooleanBinding activeFileEditorIsNull =
- getFileEditorPane().activeFileEditorProperty().isNull();
-
- // File actions
- final Action fileNewAction = new ActionBuilder()
- .setText( "Main.menu.file.new" )
- .setAccelerator( "Shortcut+N" )
- .setIcon( FILE_ALT )
- .setAction( e -> fileNew() )
- .build();
- final Action fileOpenAction = new ActionBuilder()
- .setText( "Main.menu.file.open" )
- .setAccelerator( "Shortcut+O" )
- .setIcon( FOLDER_OPEN_ALT )
- .setAction( e -> fileOpen() )
- .build();
- final Action fileCloseAction = new ActionBuilder()
- .setText( "Main.menu.file.close" )
- .setAccelerator( "Shortcut+W" )
- .setAction( e -> fileClose() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action fileCloseAllAction = new ActionBuilder()
- .setText( "Main.menu.file.close_all" )
- .setAction( e -> fileCloseAll() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action fileSaveAction = new ActionBuilder()
- .setText( "Main.menu.file.save" )
- .setAccelerator( "Shortcut+S" )
- .setIcon( FLOPPY_ALT )
- .setAction( e -> fileSave() )
- .setDisable( createActiveBooleanProperty(
- FileEditorTab::modifiedProperty ).not() )
- .build();
- final Action fileSaveAsAction = new ActionBuilder()
- .setText( "Main.menu.file.save_as" )
- .setAction( e -> fileSaveAs() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action fileSaveAllAction = new ActionBuilder()
- .setText( "Main.menu.file.save_all" )
- .setAccelerator( "Shortcut+Shift+S" )
- .setAction( e -> fileSaveAll() )
- .setDisable( Bindings.not(
- getFileEditorPane().anyFileEditorModifiedProperty() ) )
- .build();
- final Action fileExportAction = new ActionBuilder()
- .setText( "Main.menu.file.export" )
- .build();
- final Action fileExportHtmlSvgAction = new ActionBuilder()
- .setText( "Main.menu.file.export.html_svg" )
- .setAction( e -> fileExport( HTML_TEX_SVG ) )
- .build();
- final Action fileExportHtmlTexAction = new ActionBuilder()
- .setText( "Main.menu.file.export.html_tex" )
- .setAction( e -> fileExport( HTML_TEX_DELIMITED ) )
- .build();
- final Action fileExportMarkdownAction = new ActionBuilder()
- .setText( "Main.menu.file.export.markdown" )
- .setAction( e -> fileExport( MARKDOWN_PLAIN ) )
- .build();
- fileExportAction.addSubActions(
- fileExportHtmlSvgAction,
- fileExportHtmlTexAction,
- fileExportMarkdownAction );
-
- final Action fileExitAction = new ActionBuilder()
- .setText( "Main.menu.file.exit" )
- .setAction( e -> fileExit() )
- .build();
-
- // Edit actions
- final Action editUndoAction = new ActionBuilder()
- .setText( "Main.menu.edit.undo" )
- .setAccelerator( "Shortcut+Z" )
- .setIcon( UNDO )
- .setAction( e -> getActiveEditorPane().undo() )
- .setDisable( createActiveBooleanProperty(
- FileEditorTab::canUndoProperty ).not() )
- .build();
- final Action editRedoAction = new ActionBuilder()
- .setText( "Main.menu.edit.redo" )
- .setAccelerator( "Shortcut+Y" )
- .setIcon( REPEAT )
- .setAction( e -> getActiveEditorPane().redo() )
- .setDisable( createActiveBooleanProperty(
- FileEditorTab::canRedoProperty ).not() )
- .build();
-
- final Action editCutAction = new ActionBuilder()
- .setText( "Main.menu.edit.cut" )
- .setAccelerator( "Shortcut+X" )
- .setIcon( CUT )
- .setAction( e -> getActiveEditorPane().cut() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action editCopyAction = new ActionBuilder()
- .setText( "Main.menu.edit.copy" )
- .setAccelerator( "Shortcut+C" )
- .setIcon( COPY )
- .setAction( e -> getActiveEditorPane().copy() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action editPasteAction = new ActionBuilder()
- .setText( "Main.menu.edit.paste" )
- .setAccelerator( "Shortcut+V" )
- .setIcon( PASTE )
- .setAction( e -> getActiveEditorPane().paste() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action editSelectAllAction = new ActionBuilder()
- .setText( "Main.menu.edit.selectAll" )
- .setAccelerator( "Shortcut+A" )
- .setAction( e -> getActiveEditorPane().selectAll() )
- .setDisable( activeFileEditorIsNull )
- .build();
-
- final Action editFindAction = new ActionBuilder()
- .setText( "Main.menu.edit.find" )
- .setAccelerator( "Ctrl+F" )
- .setIcon( SEARCH )
- .setAction( e -> editFind() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action editFindNextAction = new ActionBuilder()
- .setText( "Main.menu.edit.find.next" )
- .setAccelerator( "F3" )
- .setAction( e -> editFindNext() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action editPreferencesAction = new ActionBuilder()
- .setText( "Main.menu.edit.preferences" )
- .setAccelerator( "Ctrl+Alt+S" )
- .setAction( e -> editPreferences() )
- .build();
-
- // Format actions
- final Action formatBoldAction = new ActionBuilder()
- .setText( "Main.menu.format.bold" )
- .setAccelerator( "Shortcut+B" )
- .setIcon( BOLD )
- .setAction( e -> insertMarkdown( "**", "**" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action formatItalicAction = new ActionBuilder()
- .setText( "Main.menu.format.italic" )
- .setAccelerator( "Shortcut+I" )
- .setIcon( ITALIC )
- .setAction( e -> insertMarkdown( "*", "*" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action formatSuperscriptAction = new ActionBuilder()
- .setText( "Main.menu.format.superscript" )
- .setAccelerator( "Shortcut+[" )
- .setIcon( SUPERSCRIPT )
- .setAction( e -> insertMarkdown( "^", "^" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action formatSubscriptAction = new ActionBuilder()
- .setText( "Main.menu.format.subscript" )
- .setAccelerator( "Shortcut+]" )
- .setIcon( SUBSCRIPT )
- .setAction( e -> insertMarkdown( "~", "~" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action formatStrikethroughAction = new ActionBuilder()
- .setText( "Main.menu.format.strikethrough" )
- .setAccelerator( "Shortcut+T" )
- .setIcon( STRIKETHROUGH )
- .setAction( e -> insertMarkdown( "~~", "~~" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
-
- // Insert actions
- final Action insertBlockquoteAction = new ActionBuilder()
- .setText( "Main.menu.insert.blockquote" )
- .setAccelerator( "Ctrl+Q" )
- .setIcon( QUOTE_LEFT )
- .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertCodeAction = new ActionBuilder()
- .setText( "Main.menu.insert.code" )
- .setAccelerator( "Shortcut+K" )
- .setIcon( CODE )
- .setAction( e -> insertMarkdown( "`", "`" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertFencedCodeBlockAction = new ActionBuilder()
- .setText( "Main.menu.insert.fenced_code_block" )
- .setAccelerator( "Shortcut+Shift+K" )
- .setIcon( FILE_CODE_ALT )
- .setAction( e -> insertMarkdown(
- "\n\n```\n",
- "\n```\n\n",
- get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertLinkAction = new ActionBuilder()
- .setText( "Main.menu.insert.link" )
- .setAccelerator( "Shortcut+L" )
- .setIcon( LINK )
- .setAction( e -> getActiveEditorPane().insertLink() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertImageAction = new ActionBuilder()
- .setText( "Main.menu.insert.image" )
- .setAccelerator( "Shortcut+G" )
- .setIcon( PICTURE_ALT )
- .setAction( e -> getActiveEditorPane().insertImage() )
- .setDisable( activeFileEditorIsNull )
- .build();
-
- // Number of heading actions (H1 ... H3)
- final int HEADINGS = 3;
- final Action[] headings = new Action[ HEADINGS ];
-
- for( int i = 1; i <= HEADINGS; i++ ) {
- final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
- final String markup = String.format( "%n%n%s ", hashes );
- final String text = "Main.menu.insert.heading." + i;
- final String accelerator = "Shortcut+" + i;
- final String prompt = text + ".prompt";
-
- headings[ i - 1 ] = new ActionBuilder()
- .setText( text )
- .setAccelerator( accelerator )
- .setIcon( HEADER )
- .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- }
-
- final Action insertUnorderedListAction = new ActionBuilder()
- .setText( "Main.menu.insert.unordered_list" )
- .setAccelerator( "Shortcut+U" )
- .setIcon( LIST_UL )
- .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertOrderedListAction = new ActionBuilder()
- .setText( "Main.menu.insert.ordered_list" )
- .setAccelerator( "Shortcut+Shift+O" )
- .setIcon( LIST_OL )
- .setAction( e -> insertMarkdown(
- "\n\n1. ", "" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertHorizontalRuleAction = new ActionBuilder()
- .setText( "Main.menu.insert.horizontal_rule" )
- .setAccelerator( "Shortcut+H" )
- .setAction( e -> insertMarkdown(
- "\n\n---\n\n", "" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
-
- // Definition actions
- final Action definitionCreateAction = new ActionBuilder()
- .setText( "Main.menu.definition.create" )
- .setIcon( TREE )
- .setAction( e -> getDefinitionPane().addItem() )
- .build();
- final Action definitionInsertAction = new ActionBuilder()
- .setText( "Main.menu.definition.insert" )
- .setAccelerator( "Ctrl+Space" )
- .setIcon( STAR )
- .setAction( e -> definitionInsert() )
- .build();
-
- // Help actions
- final Action helpAboutAction = new ActionBuilder()
+import com.keenwrite.editors.markdown.MarkdownEditorPane;
+import com.keenwrite.exceptions.MissingFileException;
+import com.keenwrite.preferences.UserPreferences;
+import com.keenwrite.preview.HTMLPreviewPane;
+import com.keenwrite.processors.Processor;
+import com.keenwrite.processors.ProcessorContext;
+import com.keenwrite.processors.ProcessorFactory;
+import com.keenwrite.processors.markdown.MarkdownProcessor;
+import com.keenwrite.service.Options;
+import com.keenwrite.service.Snitch;
+import com.keenwrite.spelling.api.SpellCheckListener;
+import com.keenwrite.spelling.api.SpellChecker;
+import com.keenwrite.spelling.impl.PermissiveSpeller;
+import com.keenwrite.spelling.impl.SymSpellSpeller;
+import com.keenwrite.util.Action;
+import com.keenwrite.util.ActionUtils;
+import com.keenwrite.util.SeparatorAction;
+import com.vladsch.flexmark.parser.Parser;
+import com.vladsch.flexmark.util.ast.NodeVisitor;
+import com.vladsch.flexmark.util.ast.VisitHandler;
+import javafx.beans.binding.Bindings;
+import javafx.beans.binding.BooleanBinding;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableBooleanValue;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ListChangeListener.Change;
+import javafx.collections.ObservableList;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.control.*;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.VBox;
+import javafx.scene.text.Text;
+import javafx.stage.FileChooser;
+import javafx.stage.Window;
+import javafx.stage.WindowEvent;
+import javafx.util.Duration;
+import org.apache.commons.lang3.SystemUtils;
+import org.controlsfx.control.StatusBar;
+import org.fxmisc.richtext.StyleClassedTextArea;
+import org.fxmisc.richtext.model.StyleSpansBuilder;
+import org.reactfx.value.Val;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.prefs.Preferences;
+import java.util.stream.Collectors;
+
+import static com.keenwrite.Bootstrap.APP_TITLE;
+import static com.keenwrite.Constants.*;
+import static com.keenwrite.ExportFormat.*;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.processors.ProcessorFactory.processChain;
+import static com.keenwrite.util.StageState.*;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.Files.writeString;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singleton;
+import static javafx.application.Platform.runLater;
+import static javafx.event.Event.fireEvent;
+import static javafx.scene.control.Alert.AlertType.INFORMATION;
+import static javafx.scene.input.KeyCode.ENTER;
+import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
+import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
+
+/**
+ * Main window containing a tab pane in the center for file editors.
+ */
+public class MainWindow implements Observer {
+ /**
+ * The {@code OPTIONS} variable must be declared before all other variables
+ * to prevent subsequent initializations from failing due to missing user
+ * preferences.
+ */
+ private static final Options sOptions = Services.load( Options.class );
+ private static final Snitch SNITCH = Services.load( Snitch.class );
+
+ private final Scene mScene;
+ private final StatusBar mStatusBar;
+ private final Text mLineNumberText;
+ private final TextField mFindTextField;
+ private final SpellChecker mSpellChecker;
+
+ private final Object mMutex = new Object();
+
+ /**
+ * Prevents re-instantiation of processing classes.
+ */
+ private final Map<FileEditorTab, Processor<String>> mProcessors =
+ new HashMap<>();
+
+ private final Map<String, String> mResolvedMap =
+ new HashMap<>( DEFAULT_MAP_SIZE );
+
+ private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
+ event -> rerender();
+
+ /**
+ * Called when the definition data is changed.
+ */
+ private final EventHandler<TreeItem.TreeModificationEvent<Event>>
+ mTreeHandler = event -> {
+ exportDefinitions( getDefinitionPath() );
+ interpolateResolvedMap();
+ rerender();
+ };
+
+ /**
+ * Called to inject the selected item when the user presses ENTER in the
+ * definition pane.
+ */
+ private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
+ event -> {
+ if( event.getCode() == ENTER ) {
+ getDefinitionNameInjector().injectSelectedItem();
+ }
+ };
+
+ private final ChangeListener<Integer> mCaretPositionListener =
+ ( observable, oldPosition, newPosition ) -> {
+ processActiveTab();
+ };
+
+ private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
+ private final DefinitionPane mDefinitionPane = createDefinitionPane();
+ private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
+ private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
+ mCaretPositionListener );
+
+ /**
+ * Listens on the definition pane for double-click events.
+ */
+ private final DefinitionNameInjector mDefinitionNameInjector
+ = new DefinitionNameInjector( mDefinitionPane );
+
+ public MainWindow() {
+ mStatusBar = createStatusBar();
+ mLineNumberText = createLineNumberText();
+ mFindTextField = createFindTextField();
+ mScene = createScene();
+ mSpellChecker = createSpellChecker();
+
+ // Add the close request listener before the window is shown.
+ initLayout();
+ StatusBarNotifier.setStatusBar( mStatusBar );
+ }
+
+ /**
+ * Called after the stage is shown.
+ */
+ public void init() {
+ initFindInput();
+ initSnitch();
+ initDefinitionListener();
+ initTabAddedListener();
+ initTabChangedListener();
+ initPreferences();
+ initVariableNameInjector();
+ }
+
+ private void initLayout() {
+ final var scene = getScene();
+
+ scene.getStylesheets().add( STYLESHEET_SCENE );
+ scene.windowProperty().addListener(
+ ( unused, oldWindow, newWindow ) ->
+ newWindow.setOnCloseRequest(
+ e -> {
+ if( !getFileEditorPane().closeAllEditors() ) {
+ e.consume();
+ }
+ }
+ )
+ );
+ }
+
+ /**
+ * Initialize the find input text field to listen on F3, ENTER, and
+ * ESCAPE key presses.
+ */
+ private void initFindInput() {
+ final TextField input = getFindTextField();
+
+ input.setOnKeyPressed( ( KeyEvent event ) -> {
+ switch( event.getCode() ) {
+ case F3:
+ case ENTER:
+ editFindNext();
+ break;
+ case F:
+ if( !event.isControlDown() ) {
+ break;
+ }
+ case ESCAPE:
+ getStatusBar().setGraphic( null );
+ getActiveFileEditorTab().getEditorPane().requestFocus();
+ break;
+ }
+ } );
+
+ // Remove when the input field loses focus.
+ input.focusedProperty().addListener(
+ ( focused, oldFocus, newFocus ) -> {
+ if( !newFocus ) {
+ getStatusBar().setGraphic( null );
+ }
+ }
+ );
+ }
+
+ /**
+ * Watch for changes to external files. In particular, this awaits
+ * modifications to any XSL files associated with XML files being edited.
+ * When
+ * an XSL file is modified (external to the application), the snitch's ears
+ * perk up and the file is reloaded. This keeps the XSL transformation up to
+ * date with what's on the file system.
+ */
+ private void initSnitch() {
+ SNITCH.addObserver( this );
+ }
+
+ /**
+ * Listen for {@link FileEditorTabPane} to receive open definition file
+ * event.
+ */
+ private void initDefinitionListener() {
+ getFileEditorPane().onOpenDefinitionFileProperty().addListener(
+ ( final ObservableValue<? extends Path> file,
+ final Path oldPath, final Path newPath ) -> {
+ openDefinitions( newPath );
+ rerender();
+ }
+ );
+ }
+
+ /**
+ * Re-instantiates all processors then re-renders the active tab. This
+ * will refresh the resolved map, force R to re-initialize, and brute-force
+ * XSLT file reloads.
+ */
+ private void rerender() {
+ runLater(
+ () -> {
+ resetProcessors();
+ processActiveTab();
+ }
+ );
+ }
+
+ /**
+ * When tabs are added, hook the various change listeners onto the new
+ * tab sothat the preview pane refreshes as necessary.
+ */
+ private void initTabAddedListener() {
+ final FileEditorTabPane editorPane = getFileEditorPane();
+
+ // Make sure the text processor kicks off when new files are opened.
+ final ObservableList<Tab> tabs = editorPane.getTabs();
+
+ // Update the preview pane on tab changes.
+ tabs.addListener(
+ ( final Change<? extends Tab> change ) -> {
+ while( change.next() ) {
+ if( change.wasAdded() ) {
+ // Multiple tabs can be added simultaneously.
+ for( final Tab newTab : change.getAddedSubList() ) {
+ final FileEditorTab tab = (FileEditorTab) newTab;
+
+ initTextChangeListener( tab );
+ initScrollEventListener( tab );
+ initSpellCheckListener( tab );
+// initSyntaxListener( tab );
+ }
+ }
+ }
+ }
+ );
+ }
+
+ private void initTextChangeListener( final FileEditorTab tab ) {
+ tab.addTextChangeListener(
+ ( __, ov, nv ) -> {
+ process( tab );
+ }
+ );
+ }
+
+ private void initScrollEventListener( final FileEditorTab tab ) {
+ final var scrollPane = tab.getScrollPane();
+ final var scrollBar = getPreviewPane().getVerticalScrollBar();
+
+ addShowListener( scrollPane, ( __ ) -> {
+ final var handler = new ScrollEventHandler( scrollPane, scrollBar );
+ handler.enabledProperty().bind( tab.selectedProperty() );
+ } );
+ }
+
+ /**
+ * Listen for changes to the any particular paragraph and perform a quick
+ * spell check upon it. The style classes in the editor will be changed to
+ * mark any spelling mistakes in the paragraph. The user may then interact
+ * with any misspelled word (i.e., any piece of text that is marked) to
+ * revise the spelling.
+ *
+ * @param tab The tab to spellcheck.
+ */
+ private void initSpellCheckListener( final FileEditorTab tab ) {
+ final var editor = tab.getEditorPane().getEditor();
+
+ // When the editor first appears, run a full spell check. This allows
+ // spell checking while typing to be restricted to the active paragraph,
+ // which is usually substantially smaller than the whole document.
+ addShowListener(
+ editor, ( __ ) -> spellcheck( editor, editor.getText() )
+ );
+
+ // Use the plain text changes so that notifications of style changes
+ // are suppressed. Checking against the identity ensures that only
+ // new text additions or deletions trigger proofreading.
+ editor.plainTextChanges()
+ .filter( p -> !p.isIdentity() ).subscribe( change -> {
+
+ // Only perform a spell check on the current paragraph. The
+ // entire document is processed once, when opened.
+ final var offset = change.getPosition();
+ final var position = editor.offsetToPosition( offset, Forward );
+ final var paraId = position.getMajor();
+ final var paragraph = editor.getParagraph( paraId );
+ final var text = paragraph.getText();
+
+ // Ensure that styles aren't doubled-up.
+ editor.clearStyle( paraId );
+
+ spellcheck( editor, text, paraId );
+ } );
+ }
+
+ /**
+ * Listen for new tab selection events.
+ */
+ private void initTabChangedListener() {
+ final FileEditorTabPane editorPane = getFileEditorPane();
+
+ // Update the preview pane changing tabs.
+ editorPane.addTabSelectionListener(
+ ( __, oldTab, newTab ) -> {
+ if( newTab == null ) {
+ // Clear the preview pane when closing an editor. When the last
+ // tab is closed, this ensures that the preview pane is empty.
+ getPreviewPane().clear();
+ }
+ else {
+ final var tab = (FileEditorTab) newTab;
+ updateVariableNameInjector( tab );
+ process( tab );
+ }
+ }
+ );
+ }
+
+ /**
+ * Reloads the preferences from the previous session.
+ */
+ private void initPreferences() {
+ initDefinitionPane();
+ getFileEditorPane().initPreferences();
+ getUserPreferences().addSaveEventHandler( mRPreferencesListener );
+ }
+
+ private void initVariableNameInjector() {
+ updateVariableNameInjector( getActiveFileEditorTab() );
+ }
+
+ /**
+ * Calls the listener when the given node is shown for the first time. The
+ * visible property is not the same as the initial showing event; visibility
+ * can be triggered numerous times (such as going off screen).
+ * <p>
+ * This is called, for example, before the drag handler can be attached,
+ * because the scrollbar for the text editor pane must be visible.
+ * </p>
+ *
+ * @param node The node to watch for showing.
+ * @param consumer The consumer to invoke when the event fires.
+ */
+ private void addShowListener(
+ final Node node, final Consumer<Void> consumer ) {
+ final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
+ runLater( () -> {
+ if( newShow != null && newShow ) {
+ try {
+ consumer.accept( null );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+ } );
+
+ Val.flatMap( node.sceneProperty(), Scene::windowProperty )
+ .flatMap( Window::showingProperty )
+ .addListener( listener );
+ }
+
+ private void scrollToCaret() {
+ synchronized( mMutex ) {
+ final var previewPane = getPreviewPane();
+
+ previewPane.scrollTo( CARET_ID );
+ previewPane.repaintScrollPane();
+ }
+ }
+
+ private void updateVariableNameInjector( final FileEditorTab tab ) {
+ getDefinitionNameInjector().addListener( tab );
+ }
+
+ /**
+ * Called to update the status bar's caret position when a new tab is added
+ * or the active tab is switched.
+ *
+ * @param tab The active tab containing a caret position to show.
+ */
+ private void updateCaretStatus( final FileEditorTab tab ) {
+ getLineNumberText().setText( tab.getCaretPosition().toString() );
+ }
+
+ /**
+ * Called whenever the preview pane becomes out of sync with the file editor
+ * tab. This can be called when the text changes, the caret paragraph
+ * changes, or the file tab changes.
+ *
+ * @param tab The file editor tab that has been changed in some fashion.
+ */
+ private void process( final FileEditorTab tab ) {
+ if( tab != null ) {
+ getPreviewPane().setPath( tab.getPath() );
+
+ final Processor<String> processor = getProcessors().computeIfAbsent(
+ tab, p -> createProcessors( tab )
+ );
+
+ try {
+ updateCaretStatus( tab );
+ processChain( processor, tab.getEditorText() );
+ scrollToCaret();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+ }
+
+ private void processActiveTab() {
+ process( getActiveFileEditorTab() );
+ }
+
+ /**
+ * Called when a definition source is opened.
+ *
+ * @param path Path to the definition source that was opened.
+ */
+ private void openDefinitions( final Path path ) {
+ try {
+ final var ds = createDefinitionSource( path );
+ setDefinitionSource( ds );
+
+ final var prefs = getUserPreferences();
+ prefs.definitionPathProperty().setValue( path.toFile() );
+ prefs.save();
+
+ final var tooltipPath = new Tooltip( path.toString() );
+ tooltipPath.setShowDelay( Duration.millis( 200 ) );
+
+ final var pane = getDefinitionPane();
+ pane.update( ds );
+ pane.addTreeChangeHandler( mTreeHandler );
+ pane.addKeyEventHandler( mDefinitionKeyHandler );
+ pane.filenameProperty().setValue( path.getFileName().toString() );
+ pane.setTooltip( tooltipPath );
+
+ interpolateResolvedMap();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private void exportDefinitions( final Path path ) {
+ try {
+ final var pane = getDefinitionPane();
+ final var root = pane.getTreeView().getRoot();
+ final var problemChild = pane.isTreeWellFormed();
+
+ if( problemChild == null ) {
+ getDefinitionSource().getTreeAdapter().export( root, path );
+ }
+ else {
+ clue( "yaml.error.tree.form", problemChild.getValue() );
+ }
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private void interpolateResolvedMap() {
+ final var treeMap = getDefinitionPane().toMap();
+ final var map = new HashMap<>( treeMap );
+ MapInterpolator.interpolate( map );
+
+ getResolvedMap().clear();
+ getResolvedMap().putAll( map );
+ }
+
+ private void initDefinitionPane() {
+ openDefinitions( getDefinitionPath() );
+ }
+
+ //---- File actions -------------------------------------------------------
+
+ /**
+ * Called when an {@link Observable} instance has changed. This is called
+ * by both the {@link Snitch} service and the notify service. The @link
+ * Snitch} service can be called for different file types, including
+ * {@link DefinitionSource} instances.
+ *
+ * @param observable The observed instance.
+ * @param value The noteworthy item.
+ */
+ @Override
+ public void update( final Observable observable, final Object value ) {
+ if( value instanceof Path && observable instanceof Snitch ) {
+ updateSelectedTab();
+ }
+ }
+
+ /**
+ * Called when a file has been modified.
+ */
+ private void updateSelectedTab() {
+ rerender();
+ }
+
+ /**
+ * After resetting the processors, they will refresh anew to be up-to-date
+ * with the files (text and definition) currently loaded into the editor.
+ */
+ private void resetProcessors() {
+ getProcessors().clear();
+ }
+
+ //---- File actions -------------------------------------------------------
+
+ private void fileNew() {
+ getFileEditorPane().newEditor();
+ }
+
+ private void fileOpen() {
+ getFileEditorPane().openFileDialog();
+ }
+
+ private void fileClose() {
+ getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
+ }
+
+ /**
+ * TODO: Upon closing, first remove the tab change listeners. (There's no
+ * need to re-render each tab when all are being closed.)
+ */
+ private void fileCloseAll() {
+ getFileEditorPane().closeAllEditors();
+ }
+
+ private void fileSave() {
+ getFileEditorPane().saveEditor( getActiveFileEditorTab() );
+ }
+
+ private void fileSaveAs() {
+ final FileEditorTab editor = getActiveFileEditorTab();
+ getFileEditorPane().saveEditorAs( editor );
+ getProcessors().remove( editor );
+
+ try {
+ process( editor );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private void fileSaveAll() {
+ getFileEditorPane().saveAllEditors();
+ }
+
+ /**
+ * Exports the contents of the current tab according to the given
+ * {@link ExportFormat}.
+ *
+ * @param format Configures the {@link MarkdownProcessor} when exporting.
+ */
+ private void fileExport( final ExportFormat format ) {
+ final var tab = getActiveFileEditorTab();
+ final var context = createProcessorContext( tab, format );
+ final var chain = ProcessorFactory.createProcessors( context );
+ final var doc = tab.getEditorText();
+ final var export = processChain( chain, doc );
+
+ final var filename = format.toExportFilename( tab.getPath().toFile() );
+ final var dir = getPreferences().get( "lastDirectory", null );
+ final var lastDir = new File( dir == null ? "." : dir );
+
+ final FileChooser chooser = new FileChooser();
+ chooser.setTitle( get( "Dialog.file.choose.export.title" ) );
+ chooser.setInitialFileName( filename.getName() );
+ chooser.setInitialDirectory( lastDir );
+
+ final File file = chooser.showSaveDialog( getWindow() );
+
+ if( file != null ) {
+ try {
+ writeString( file.toPath(), export, UTF_8 );
+ final var m = get( "Main.status.export.success", file.toString() );
+ clue( m );
+ } catch( final IOException e ) {
+ clue( e );
+ }
+ }
+ }
+
+ private void fileExit() {
+ final Window window = getWindow();
+ fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
+ }
+
+ //---- Edit actions -------------------------------------------------------
+
+ /**
+ * Used to find text in the active file editor window.
+ */
+ private void editFind() {
+ final TextField input = getFindTextField();
+ getStatusBar().setGraphic( input );
+ input.requestFocus();
+ }
+
+ public void editFindNext() {
+ getActiveFileEditorTab().searchNext( getFindTextField().getText() );
+ }
+
+ public void editPreferences() {
+ getUserPreferences().show();
+ }
+
+ //---- Insert actions -----------------------------------------------------
+
+ /**
+ * Delegates to the active editor to handle wrapping the current text
+ * selection with leading and trailing strings.
+ *
+ * @param leading The string to put before the selection.
+ * @param trailing The string to put after the selection.
+ */
+ private void insertMarkdown(
+ final String leading, final String trailing ) {
+ getActiveEditorPane().surroundSelection( leading, trailing );
+ }
+
+ private void insertMarkdown(
+ final String leading, final String trailing, final String hint ) {
+ getActiveEditorPane().surroundSelection( leading, trailing, hint );
+ }
+
+ //---- Help actions -------------------------------------------------------
+
+ private void helpAbout() {
+ final Alert alert = new Alert( INFORMATION );
+ alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
+ alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
+ alert.setContentText( get( "Dialog.about.content" ) );
+ alert.setGraphic( new ImageView( ICON_DIALOG ) );
+ alert.initOwner( getWindow() );
+
+ alert.showAndWait();
+ }
+
+ //---- Member creators ----------------------------------------------------
+
+ private SpellChecker createSpellChecker() {
+ try {
+ final Collection<String> lexicon = readLexicon( "en.txt" );
+ return SymSpellSpeller.forLexicon( lexicon );
+ } catch( final Exception ex ) {
+ clue( ex );
+ return new PermissiveSpeller();
+ }
+ }
+
+ /**
+ * Creates processors suited to parsing and rendering different file types.
+ *
+ * @param tab The tab that is subjected to processing.
+ * @return A processor suited to the file type specified by the tab's path.
+ */
+ private Processor<String> createProcessors( final FileEditorTab tab ) {
+ final var context = createProcessorContext( tab );
+ return ProcessorFactory.createProcessors( context );
+ }
+
+ private ProcessorContext createProcessorContext(
+ final FileEditorTab tab, final ExportFormat format ) {
+ final var pane = getPreviewPane();
+ final var map = getResolvedMap();
+ return new ProcessorContext( pane, map, tab, format );
+ }
+
+ private ProcessorContext createProcessorContext( final FileEditorTab tab ) {
+ return createProcessorContext( tab, NONE );
+ }
+
+ private DefinitionPane createDefinitionPane() {
+ return new DefinitionPane();
+ }
+
+ private HTMLPreviewPane createHTMLPreviewPane() {
+ return new HTMLPreviewPane();
+ }
+
+ private DefinitionSource createDefaultDefinitionSource() {
+ return new YamlDefinitionSource( getDefinitionPath() );
+ }
+
+ private DefinitionSource createDefinitionSource( final Path path ) {
+ try {
+ return createDefinitionFactory().createDefinitionSource( path );
+ } catch( final Exception ex ) {
+ clue( ex );
+ return createDefaultDefinitionSource();
+ }
+ }
+
+ private TextField createFindTextField() {
+ return new TextField();
+ }
+
+ private DefinitionFactory createDefinitionFactory() {
+ return new DefinitionFactory();
+ }
+
+ private StatusBar createStatusBar() {
+ return new StatusBar();
+ }
+
+ private Scene createScene() {
+ final SplitPane splitPane = new SplitPane(
+ getDefinitionPane(),
+ getFileEditorPane(),
+ getPreviewPane() );
+
+ splitPane.setDividerPositions(
+ getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
+ getFloat( K_PANE_SPLIT_EDITOR, .60f ),
+ getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
+
+ getDefinitionPane().prefHeightProperty()
+ .bind( splitPane.heightProperty() );
+
+ final BorderPane borderPane = new BorderPane();
+ borderPane.setPrefSize( 1280, 800 );
+ borderPane.setTop( createMenuBar() );
+ borderPane.setBottom( getStatusBar() );
+ borderPane.setCenter( splitPane );
+
+ final VBox statusBar = new VBox();
+ statusBar.setAlignment( Pos.BASELINE_CENTER );
+ statusBar.getChildren().add( getLineNumberText() );
+ getStatusBar().getRightItems().add( statusBar );
+
+ // Force preview pane refresh on Windows.
+ if( SystemUtils.IS_OS_WINDOWS ) {
+ splitPane.getDividers().get( 1 ).positionProperty().addListener(
+ ( l, oValue, nValue ) -> runLater(
+ () -> getPreviewPane().repaintScrollPane()
+ )
+ );
+ }
+
+ return new Scene( borderPane );
+ }
+
+ private Text createLineNumberText() {
+ return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
+ }
+
+ private Node createMenuBar() {
+ final BooleanBinding activeFileEditorIsNull =
+ getFileEditorPane().activeFileEditorProperty().isNull();
+
+ // File actions
+ final Action fileNewAction = Action
+ .builder()
+ .setText( "Main.menu.file.new" )
+ .setAccelerator( "Shortcut+N" )
+ .setIcon( FILE_ALT )
+ .setAction( e -> fileNew() )
+ .build();
+ final Action fileOpenAction = Action
+ .builder()
+ .setText( "Main.menu.file.open" )
+ .setAccelerator( "Shortcut+O" )
+ .setIcon( FOLDER_OPEN_ALT )
+ .setAction( e -> fileOpen() )
+ .build();
+ final Action fileCloseAction = Action
+ .builder()
+ .setText( "Main.menu.file.close" )
+ .setAccelerator( "Shortcut+W" )
+ .setAction( e -> fileClose() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action fileCloseAllAction = Action
+ .builder()
+ .setText( "Main.menu.file.close_all" )
+ .setAction( e -> fileCloseAll() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action fileSaveAction = Action
+ .builder()
+ .setText( "Main.menu.file.save" )
+ .setAccelerator( "Shortcut+S" )
+ .setIcon( FLOPPY_ALT )
+ .setAction( e -> fileSave() )
+ .setDisabled( createActiveBooleanProperty(
+ FileEditorTab::modifiedProperty ).not() )
+ .build();
+ final Action fileSaveAsAction = Action
+ .builder()
+ .setText( "Main.menu.file.save_as" )
+ .setAction( e -> fileSaveAs() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action fileSaveAllAction = Action
+ .builder()
+ .setText( "Main.menu.file.save_all" )
+ .setAccelerator( "Shortcut+Shift+S" )
+ .setAction( e -> fileSaveAll() )
+ .setDisabled( Bindings.not(
+ getFileEditorPane().anyFileEditorModifiedProperty() ) )
+ .build();
+ final Action fileExportAction = Action
+ .builder()
+ .setText( "Main.menu.file.export" )
+ .build();
+ final Action fileExportHtmlSvgAction = Action
+ .builder()
+ .setText( "Main.menu.file.export.html_svg" )
+ .setAction( e -> fileExport( HTML_TEX_SVG ) )
+ .build();
+ final Action fileExportHtmlTexAction = Action
+ .builder()
+ .setText( "Main.menu.file.export.html_tex" )
+ .setAction( e -> fileExport( HTML_TEX_DELIMITED ) )
+ .build();
+ final Action fileExportMarkdownAction = Action
+ .builder()
+ .setText( "Main.menu.file.export.markdown" )
+ .setAction( e -> fileExport( MARKDOWN_PLAIN ) )
+ .build();
+ fileExportAction.addSubActions(
+ fileExportHtmlSvgAction,
+ fileExportHtmlTexAction,
+ fileExportMarkdownAction );
+
+ final Action fileExitAction = Action
+ .builder()
+ .setText( "Main.menu.file.exit" )
+ .setAction( e -> fileExit() )
+ .build();
+
+ // Edit actions
+ final Action editUndoAction = Action
+ .builder()
+ .setText( "Main.menu.edit.undo" )
+ .setAccelerator( "Shortcut+Z" )
+ .setIcon( UNDO )
+ .setAction( e -> getActiveEditorPane().undo() )
+ .setDisabled( createActiveBooleanProperty(
+ FileEditorTab::canUndoProperty ).not() )
+ .build();
+ final Action editRedoAction = Action
+ .builder()
+ .setText( "Main.menu.edit.redo" )
+ .setAccelerator( "Shortcut+Y" )
+ .setIcon( REPEAT )
+ .setAction( e -> getActiveEditorPane().redo() )
+ .setDisabled( createActiveBooleanProperty(
+ FileEditorTab::canRedoProperty ).not() )
+ .build();
+
+ final Action editCutAction = Action
+ .builder()
+ .setText( "Main.menu.edit.cut" )
+ .setAccelerator( "Shortcut+X" )
+ .setIcon( CUT )
+ .setAction( e -> getActiveEditorPane().cut() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editCopyAction = Action
+ .builder()
+ .setText( "Main.menu.edit.copy" )
+ .setAccelerator( "Shortcut+C" )
+ .setIcon( COPY )
+ .setAction( e -> getActiveEditorPane().copy() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editPasteAction = Action
+ .builder()
+ .setText( "Main.menu.edit.paste" )
+ .setAccelerator( "Shortcut+V" )
+ .setIcon( PASTE )
+ .setAction( e -> getActiveEditorPane().paste() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editSelectAllAction = Action
+ .builder()
+ .setText( "Main.menu.edit.selectAll" )
+ .setAccelerator( "Shortcut+A" )
+ .setAction( e -> getActiveEditorPane().selectAll() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+
+ final Action editFindAction = Action
+ .builder()
+ .setText( "Main.menu.edit.find" )
+ .setAccelerator( "Ctrl+F" )
+ .setIcon( SEARCH )
+ .setAction( e -> editFind() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editFindNextAction = Action
+ .builder()
+ .setText( "Main.menu.edit.find.next" )
+ .setAccelerator( "F3" )
+ .setAction( e -> editFindNext() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editPreferencesAction = Action
+ .builder()
+ .setText( "Main.menu.edit.preferences" )
+ .setAccelerator( "Ctrl+Alt+S" )
+ .setAction( e -> editPreferences() )
+ .build();
+
+ // Format actions
+ final Action formatBoldAction = Action
+ .builder()
+ .setText( "Main.menu.format.bold" )
+ .setAccelerator( "Shortcut+B" )
+ .setIcon( BOLD )
+ .setAction( e -> insertMarkdown( "**", "**" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action formatItalicAction = Action
+ .builder()
+ .setText( "Main.menu.format.italic" )
+ .setAccelerator( "Shortcut+I" )
+ .setIcon( ITALIC )
+ .setAction( e -> insertMarkdown( "*", "*" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action formatSuperscriptAction = Action
+ .builder()
+ .setText( "Main.menu.format.superscript" )
+ .setAccelerator( "Shortcut+[" )
+ .setIcon( SUPERSCRIPT )
+ .setAction( e -> insertMarkdown( "^", "^" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action formatSubscriptAction = Action
+ .builder()
+ .setText( "Main.menu.format.subscript" )
+ .setAccelerator( "Shortcut+]" )
+ .setIcon( SUBSCRIPT )
+ .setAction( e -> insertMarkdown( "~", "~" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action formatStrikethroughAction = Action
+ .builder()
+ .setText( "Main.menu.format.strikethrough" )
+ .setAccelerator( "Shortcut+T" )
+ .setIcon( STRIKETHROUGH )
+ .setAction( e -> insertMarkdown( "~~", "~~" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+
+ // Insert actions
+ final Action insertBlockquoteAction = Action
+ .builder()
+ .setText( "Main.menu.insert.blockquote" )
+ .setAccelerator( "Ctrl+Q" )
+ .setIcon( QUOTE_LEFT )
+ .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertCodeAction = Action
+ .builder()
+ .setText( "Main.menu.insert.code" )
+ .setAccelerator( "Shortcut+K" )
+ .setIcon( CODE )
+ .setAction( e -> insertMarkdown( "`", "`" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertFencedCodeBlockAction = Action
+ .builder()
+ .setText( "Main.menu.insert.fenced_code_block" )
+ .setAccelerator( "Shortcut+Shift+K" )
+ .setIcon( FILE_CODE_ALT )
+ .setAction( e -> insertMarkdown(
+ "\n\n```\n",
+ "\n```\n\n",
+ get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertLinkAction = Action
+ .builder()
+ .setText( "Main.menu.insert.link" )
+ .setAccelerator( "Shortcut+L" )
+ .setIcon( LINK )
+ .setAction( e -> getActiveEditorPane().insertLink() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertImageAction = Action
+ .builder()
+ .setText( "Main.menu.insert.image" )
+ .setAccelerator( "Shortcut+G" )
+ .setIcon( PICTURE_ALT )
+ .setAction( e -> getActiveEditorPane().insertImage() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+
+ // Number of heading actions (H1 ... H3)
+ final int HEADINGS = 3;
+ final Action[] headings = new Action[ HEADINGS ];
+
+ for( int i = 1; i <= HEADINGS; i++ ) {
+ final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
+ final String markup = String.format( "%n%n%s ", hashes );
+ final String text = "Main.menu.insert.heading." + i;
+ final String accelerator = "Shortcut+" + i;
+ final String prompt = text + ".prompt";
+
+ headings[ i - 1 ] = Action
+ .builder()
+ .setText( text )
+ .setAccelerator( accelerator )
+ .setIcon( HEADER )
+ .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ }
+
+ final Action insertUnorderedListAction = Action
+ .builder()
+ .setText( "Main.menu.insert.unordered_list" )
+ .setAccelerator( "Shortcut+U" )
+ .setIcon( LIST_UL )
+ .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertOrderedListAction = Action
+ .builder()
+ .setText( "Main.menu.insert.ordered_list" )
+ .setAccelerator( "Shortcut+Shift+O" )
+ .setIcon( LIST_OL )
+ .setAction( e -> insertMarkdown(
+ "\n\n1. ", "" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertHorizontalRuleAction = Action
+ .builder()
+ .setText( "Main.menu.insert.horizontal_rule" )
+ .setAccelerator( "Shortcut+H" )
+ .setAction( e -> insertMarkdown(
+ "\n\n---\n\n", "" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+
+ // Definition actions
+ final Action definitionCreateAction = Action
+ .builder()
+ .setText( "Main.menu.definition.create" )
+ .setIcon( TREE )
+ .setAction( e -> getDefinitionPane().addItem() )
+ .build();
+ final Action definitionInsertAction = Action
+ .builder()
+ .setText( "Main.menu.definition.insert" )
+ .setAccelerator( "Ctrl+Space" )
+ .setIcon( STAR )
+ .setAction( e -> definitionInsert() )
+ .build();
+
+ // Help actions
+ final Action helpAboutAction = Action
+ .builder()
.setText( "Main.menu.help.about" )
.setAction( e -> helpAbout() )
src/main/java/com/keenwrite/StatusBarNotifier.java
/**
+ * Returns the global {@link Notifier} instance that can be used for opening
+ * pop-up alert messages.
+ *
+ * @return The pop-up {@link Notifier} dispatcher.
+ */
+ public static Notifier getNotifier() {
+ return sNotifier;
+ }
+
+ /**
* Updates the status bar to show the first line of the given message.
*
* @param message The message to show in the status bar.
*/
private static void update( final String message ) {
+ try {
+ throw new RuntimeException();
+ } catch( final Exception e ) {
+ e.printStackTrace();
+ }
+
runLater(
() -> {
final var s = message == null ? "" : message;
final var i = s.indexOf( '\n' );
sStatusBar.setText( s.substring( 0, i > 0 ? i : s.length() ) );
}
);
- }
-
- /**
- * Returns the global {@link Notifier} instance that can be used for opening
- * pop-up alert messages.
- *
- * @return The pop-up {@link Notifier} dispatcher.
- */
- public static Notifier getNotifier() {
- return sNotifier;
}
}
src/main/java/com/keenwrite/editors/EditorPane.java
/**
- * Notifies observers when the caret changes paragraph.
- *
- * @param listener Receives change event.
- */
- public void addCaretParagraphListener(
- final ChangeListener<? super Integer> listener ) {
- getEditor().currentParagraphProperty().addListener( listener );
- }
-
- /**
* Notifies observers when the caret changes position.
*
src/main/java/com/keenwrite/editors/markdown/MarkdownEditorPane.java
import com.keenwrite.dialogs.LinkDialog;
import com.keenwrite.editors.EditorPane;
-import com.keenwrite.processors.markdown.BlockExtension;
import com.keenwrite.processors.markdown.MarkdownProcessor;
import com.vladsch.flexmark.ast.Link;
-import com.vladsch.flexmark.html.renderer.AttributablePart;
-import com.vladsch.flexmark.util.ast.Node;
-import com.vladsch.flexmark.util.html.MutableAttributes;
import javafx.scene.control.Dialog;
import javafx.scene.control.IndexRange;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Window;
import org.fxmisc.richtext.StyleClassedTextArea;
import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
"(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
-
- /**
- * Any of these followed by a space and a letter produce a line
- * by themselves. The ">" need not be followed by a space.
- */
- private static final Pattern PATTERN_NEW_LINE = Pattern.compile(
- "^>|(((#+)|([*+\\-])|([1-9]\\.))\\s+).+" );
public MarkdownEditorPane() {
public void insertImage() {
insertObject( createImageDialog() );
- }
-
- /**
- * Returns the editor's paragraph number that will be close to its HTML
- * paragraph ID. Ultimately this solution is flawed because there isn't
- * a straightforward correlation between the document being edited and
- * what is rendered. XML documents transformed through stylesheets have
- * no readily determined correlation. Images, tables, and other
- * objects affect the relative location of the current paragraph being
- * edited with respect to the preview pane.
- * <p>
- * See
- * {@link BlockExtension.IdAttributeProvider#setAttributes(Node, AttributablePart, MutableAttributes)}}
- * for details.
- * </p>
- * <p>
- * Injecting a token into the document, as per a previous version of the
- * application, can instruct the preview pane where to shift the viewport.
- * </p>
- *
- * @param paraIndex The paragraph index from the editor pane to scroll to
- * in the preview pane, which will be approximated if an
- * equivalent cannot be found.
- * @return A unique identifier that correlates to an equivalent paragraph
- * number once the Markdown is rendered into HTML.
- */
- public int approximateParagraphId( final int paraIndex ) {
- final StyleClassedTextArea editor = getEditor();
- final List<String> lines = new ArrayList<>( 4096 );
-
- int i = 0;
- String prevText = "";
- boolean withinFencedBlock = false;
- boolean withinCodeBlock = false;
-
- for( final var p : editor.getParagraphs() ) {
- if( i > paraIndex ) {
- break;
- }
-
- String text = p.getText().replace( '>', ' ' );
- if( text.startsWith( "```" ) ) {
- if( withinFencedBlock = !withinFencedBlock ) {
- lines.add( text );
- }
- }
-
- if( !withinFencedBlock ) {
- if( text.startsWith( " " ) ) {
- if( !withinCodeBlock ) {
- lines.add( text );
- withinCodeBlock = true;
- }
- }
- else {
- withinCodeBlock = false;
- }
- }
-
- if( !withinFencedBlock && !withinCodeBlock &&
- ((!text.isBlank() && prevText.isBlank()) ||
- PATTERN_NEW_LINE.matcher( text ).matches()) ) {
- lines.add( text );
- }
-
- prevText = text;
- i++;
- }
-
- // Scrolling index is 1-based.
- return Math.max( lines.size() - 1, 0 );
}
*/
private HyperlinkModel getHyperlink() {
- final StyleClassedTextArea textArea = getEditor();
- final String selectedText = textArea.getSelectedText();
+ final var textArea = getEditor();
+ final var selectedText = textArea.getSelectedText();
// Get the current paragraph, convert to Markdown nodes.
- final MarkdownProcessor mp = MarkdownProcessor.create();
- final int p = textArea.getCurrentParagraph();
- final String paragraph = textArea.getText( p );
- final Node node = mp.toNode( paragraph );
- final LinkVisitor visitor = new LinkVisitor( textArea.getCaretColumn() );
- final Link link = visitor.process( node );
+ final var mp = MarkdownProcessor.create();
+ final var p = textArea.getCurrentParagraph();
+ final var paragraph = textArea.getText( p );
+ final var node = mp.toNode( paragraph );
+ final var visitor = new LinkVisitor( textArea.getCaretColumn() );
+ final var link = visitor.process( node );
if( link != null ) {
private Path getParentPath() {
final Path path = getPath();
- return (path != null) ? path.getParent() : null;
+ return path != null ? path.getParent() : null;
}
src/main/java/com/keenwrite/preferences/UserPreferences.java
private final StringProperty mPropImagesOrder;
private final ObjectProperty<File> mPropDefinitionPath;
- private final StringProperty mRDelimiterBegan;
- private final StringProperty mRDelimiterEnded;
- private final StringProperty mDefDelimiterBegan;
- private final StringProperty mDefDelimiterEnded;
+ private final StringProperty mPropRDelimBegan;
+ private final StringProperty mPropRDelimEnded;
+ private final StringProperty mPropDefDelimBegan;
+ private final StringProperty mPropDefDelimEnded;
private final IntegerProperty mPropFontsSizeEditor;
);
- mDefDelimiterBegan = new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT );
- mDefDelimiterEnded = new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT );
+ mPropDefDelimBegan = new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT );
+ mPropDefDelimEnded = new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT );
- mRDelimiterBegan = new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT );
- mRDelimiterEnded = new SimpleStringProperty( R_DELIM_ENDED_DEFAULT );
+ mPropRDelimBegan = new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT );
+ mPropRDelimEnded = new SimpleStringProperty( R_DELIM_ENDED_DEFAULT );
mPropFontsSizeEditor = new SimpleIntegerProperty( (int) FONT_SIZE_EDITOR );
get( "Preferences.r.delimiter.began" ),
Setting.of( label( "Preferences.r.delimiter.began.desc" ) ),
- Setting.of( "Opening", mRDelimiterBegan )
+ Setting.of( "Opening", mPropRDelimBegan )
),
Group.of(
get( "Preferences.r.delimiter.ended" ),
Setting.of( label( "Preferences.r.delimiter.ended.desc" ) ),
- Setting.of( "Closing", mRDelimiterEnded )
+ Setting.of( "Closing", mPropRDelimEnded )
)
),
Setting.of( label(
"Preferences.definitions.delimiter.began.desc" ) ),
- Setting.of( "Opening", mDefDelimiterBegan )
+ Setting.of( "Opening", mPropDefDelimBegan )
),
Group.of(
get( "Preferences.definitions.delimiter.ended" ),
Setting.of( label(
"Preferences.definitions.delimiter.ended.desc" ) ),
- Setting.of( "Closing", mDefDelimiterEnded )
+ Setting.of( "Closing", mPropDefDelimEnded )
)
),
private StringProperty defDelimiterBegan() {
- return mDefDelimiterBegan;
+ return mPropDefDelimBegan;
}
public String getDefDelimiterBegan() {
return defDelimiterBegan().get();
}
private StringProperty defDelimiterEnded() {
- return mDefDelimiterEnded;
+ return mPropDefDelimEnded;
}
private StringProperty rDelimiterBegan() {
- return mRDelimiterBegan;
+ return mPropRDelimBegan;
}
public String getRDelimiterBegan() {
return rDelimiterBegan().get();
}
private StringProperty rDelimiterEnded() {
- return mRDelimiterEnded;
+ return mPropRDelimEnded;
}
src/main/java/com/keenwrite/preview/HTMLPreviewPane.java
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 org.jsoup.Jsoup;
-import org.jsoup.helper.W3CDom;
-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 java.nio.file.Path;
-
-import static com.keenwrite.Constants.DEFAULT_DIRECTORY;
-import static com.keenwrite.Constants.STYLESHEET_PREVIEW;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static com.keenwrite.util.ProtocolResolver.getProtocol;
-import static java.awt.Desktop.Action.BROWSE;
-import static java.awt.Desktop.getDesktop;
-import static java.lang.Math.max;
-import static javax.swing.SwingUtilities.invokeLater;
-import static org.xhtmlrenderer.swing.ImageResourceLoader.NO_OP_REPAINT_LISTENER;
-
-/**
- * HTML preview pane is responsible for rendering an HTML document.
- */
-public final class HTMLPreviewPane extends SwingNode {
-
- /**
- * Suppresses scrolling to the top on every key press.
- */
- private static class HTMLPanel extends XHTMLPanel {
- @Override
- public void resetScrollPosition() {
- }
- }
-
- /**
- * Suppresses scroll attempts until after the document has loaded.
- */
- private static final class DocumentEventHandler extends DocumentAdapter {
- 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 );
- }
- }
-
- /**
- * Ensure that images are constrained to the panel width upon resizing.
- */
- private final class ResizeListener extends ComponentAdapter {
- @Override
- public void componentResized( final ComponentEvent e ) {
- setWidth( e );
- }
-
- @Override
- public void componentShown( final ComponentEvent e ) {
- setWidth( e );
- }
-
- /**
- * Sets the width of the {@link HTMLPreviewPane} so that images can be
- * scaled to fit. The scale factor is adjusted a bit below the full width
- * to prevent the horizontal scrollbar from appearing.
- *
- * @param event The component that defines the image scaling width.
- */
- private void setWidth( final ComponentEvent event ) {
- final int width = (int) (event.getComponent().getWidth() * .95);
- HTMLPreviewPane.this.mImageLoader.widthProperty().set( width );
- }
- }
-
- /**
- * 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 class HyperlinkListener extends LinkListener {
- @Override
- public void linkClicked( final BasicPanel panel, final String link ) {
- try {
- switch( getProtocol( link ) ) {
- case HTTP:
- final var desktop = getDesktop();
-
- if( desktop.isSupported( BROWSE ) ) {
- desktop.browse( new URI( link ) );
- }
- break;
- case FILE:
- // TODO: #88 -- publish a message to the event bus.
- break;
- }
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
- }
-
- /**
- * Render CSS using points (pt) not pixels (px) to reduce the chance of
- * poor rendering.
- */
- private static final String HTML_PREFIX = "<!DOCTYPE html>"
- + "<html lang='en'>"
- + "<head><title> </title><meta charset='utf-8'/>"
- + "<link rel='stylesheet' href='" +
- HTMLPreviewPane.class.getResource( STYLESHEET_PREVIEW ) + "'/>"
- + "</head>"
- + "<body>";
-
- private static final String HTML_SUFFIX = "</body></html>";
-
- /**
- * Used to reset the {@link #mHtmlDocument} buffer so that the
- * {@link #HTML_PREFIX} need not be appended all the time.
- */
- private static final int HTML_PREFIX_LENGTH = HTML_PREFIX.length();
-
- private static final W3CDom W3C_DOM = new W3CDom();
- private static final XhtmlNamespaceHandler NS_HANDLER =
- new XhtmlNamespaceHandler();
-
- /**
- * The buffer is reused so that previous memory allocations need not repeat.
- */
- private final StringBuilder mHtmlDocument = new StringBuilder( 65536 );
-
- private final HTMLPanel mHtmlRenderer = new HTMLPanel();
- private final JScrollPane mScrollPane = new JScrollPane( mHtmlRenderer );
- private final DocumentEventHandler mDocHandler = new DocumentEventHandler();
- private final CustomImageLoader mImageLoader = new CustomImageLoader();
-
- private Path mPath = DEFAULT_DIRECTORY;
-
- /**
- * Creates a new preview pane that can scroll to the caret position within the
- * document.
- */
- public HTMLPreviewPane() {
- setStyle( "-fx-background-color: white;" );
-
- // No need to append same prefix each time the HTML content is updated.
- mHtmlDocument.append( HTML_PREFIX );
-
- // Inject an SVG renderer that produces high-quality SVG buffered images.
- final var factory = new ChainedReplacedElementFactory();
- factory.addFactory( new SvgReplacedElementFactory() );
- factory.addFactory( new SwingReplacedElementFactory(
- NO_OP_REPAINT_LISTENER, mImageLoader ) );
-
- final var context = getSharedContext();
- final var textRenderer = context.getTextRenderer();
- context.setReplacedElementFactory( factory );
- textRenderer.setSmoothingThreshold( 0 );
-
- setContent( mScrollPane );
- mHtmlRenderer.addDocumentListener( mDocHandler );
- mHtmlRenderer.addComponentListener( new ResizeListener() );
-
- // 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.
- for( final var listener : mHtmlRenderer.getMouseTrackingListeners() ) {
- if( !(listener instanceof HoverListener) ) {
- mHtmlRenderer.removeMouseTrackingListener( (FSMouseListener) listener );
- }
- }
-
- mHtmlRenderer.addMouseTrackingListener( new HyperlinkListener() );
- }
-
- /**
- * Updates the internal HTML source, loads it into the preview pane, then
- * scrolls to the caret position.
- *
- * @param html The new HTML document to display.
- */
- public void process( final String html ) {
- final var docJsoup = Jsoup.parse( decorate( html ) );
- final var docW3c = W3C_DOM.fromJsoup( docJsoup );
-
- // Access to a Swing component must occur from the Event Dispatch
- // Thread (EDT) according to Swing threading restrictions.
- invokeLater(
- () -> mHtmlRenderer.setDocument( docW3c, getBaseUrl(), NS_HANDLER )
- );
- }
-
- /**
- * Clears the preview pane by rendering an empty string.
- */
- public void clear() {
- process( "" );
- }
-
- /**
- * Scrolls to an anchor link. The anchor links are injected when the
- * HTML document is created.
- *
- * @param id The unique anchor link identifier.
- */
- 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 );
- }
-
- /**
- * Scrolls to the closest element matching the given identifier without
- * waiting for the document to be ready. Be sure the document is ready
- * before calling this method.
- *
- * @param id Paragraph index.
- */
- 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( prevId )) == null ) {
- prevId--;
- }
-
- return box;
- }
-
- private Box findNextBox( final int id ) {
- int nextId = id;
- Box box = null;
-
- while( nextId - id < 5 &&
- (box = getBoxById( nextId )) == null ) {
- nextId++;
- }
-
- return box;
- }
-
- private void scrollTo( final Point point ) {
- invokeLater( () -> 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 int id ) {
- return getSharedContext().getBoxById( Integer.toString( id ) );
- }
-
- private String decorate( final String html ) {
- // Trim the HTML back to only the prefix.
- mHtmlDocument.setLength( HTML_PREFIX_LENGTH );
-
- // Write the HTML body element followed by closing tags.
- return mHtmlDocument.append( html ).append( HTML_SUFFIX ).toString();
- }
-
- public Path getPath() {
- return mPath;
- }
-
- public void setPath( final Path path ) {
- assert path != null;
- mPath = path;
- }
-
- /**
- * Content to embed in a panel.
- *
- * @return The content to display to the user.
- */
- public Node getNode() {
- return this;
- }
-
- public JScrollPane getScrollPane() {
- return mScrollPane;
- }
-
- public JScrollBar getVerticalScrollBar() {
- return getScrollPane().getVerticalScrollBar();
- }
-
- /**
- * 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;
-
- int x = box.getAbsX();
-
- // 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 whatever gets typed won't be visible.
- int y = 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();
+import javafx.embed.swing.SwingNode;
+import javafx.scene.Node;
+import org.jsoup.Jsoup;
+import org.jsoup.helper.W3CDom;
+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 java.nio.file.Path;
+
+import static com.keenwrite.Constants.DEFAULT_DIRECTORY;
+import static com.keenwrite.Constants.STYLESHEET_PREVIEW;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.util.ProtocolResolver.getProtocol;
+import static java.awt.Desktop.Action.BROWSE;
+import static java.awt.Desktop.getDesktop;
+import static java.lang.Math.max;
+import static java.lang.String.format;
+import static javax.swing.SwingUtilities.invokeLater;
+import static org.xhtmlrenderer.swing.ImageResourceLoader.NO_OP_REPAINT_LISTENER;
+
+/**
+ * HTML preview pane is responsible for rendering an HTML document.
+ */
+public final class HTMLPreviewPane extends SwingNode {
+ /**
+ * Used to scroll to the top of the preview pane.
+ */
+ private static final Point POINT_TOP = new Point( 0, 0 );
+
+ /**
+ * Suppresses scrolling to the top on every key press.
+ */
+ private static class HTMLPanel extends XHTMLPanel {
+ @Override
+ public void resetScrollPosition() {
+ }
+ }
+
+ /**
+ * Suppresses scroll attempts until after the document has loaded.
+ */
+ private static final class DocumentEventHandler extends DocumentAdapter {
+ 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 );
+ }
+ }
+
+ /**
+ * Ensure that images are constrained to the panel width upon resizing.
+ */
+ private final class ResizeListener extends ComponentAdapter {
+ @Override
+ public void componentResized( final ComponentEvent e ) {
+ setWidth( e );
+ }
+
+ @Override
+ public void componentShown( final ComponentEvent e ) {
+ setWidth( e );
+ }
+
+ /**
+ * Sets the width of the {@link HTMLPreviewPane} so that images can be
+ * scaled to fit. The scale factor is adjusted a bit below the full width
+ * to prevent the horizontal scrollbar from appearing.
+ *
+ * @param event The component that defines the image scaling width.
+ */
+ private void setWidth( final ComponentEvent event ) {
+ final int width = (int) (event.getComponent().getWidth() * .95);
+ HTMLPreviewPane.this.mImageLoader.widthProperty().set( width );
+ }
+ }
+
+ /**
+ * 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 class HyperlinkListener extends LinkListener {
+ @Override
+ public void linkClicked( final BasicPanel panel, final String link ) {
+ try {
+ switch( getProtocol( link ) ) {
+ case HTTP:
+ final var desktop = getDesktop();
+
+ if( desktop.isSupported( BROWSE ) ) {
+ desktop.browse( new URI( link ) );
+ }
+ break;
+ case FILE:
+ // TODO: #88 -- publish a message to the event bus.
+ break;
+ }
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+ }
+
+ /**
+ * Render CSS using points (pt) not pixels (px) to reduce the chance of
+ * poor rendering.
+ */
+ private static final String HTML_PREFIX = format(
+ "<!DOCTYPE html>"
+ + "<html lang='en'>"
+ + "<head><title> </title><meta charset='utf-8'/>"
+ + "<link rel='stylesheet' href='%s'/>"
+ + "</head>"
+ + "<body>",
+ HTMLPreviewPane.class.getResource( STYLESHEET_PREVIEW )
+ );
+
+ private static final String HTML_SUFFIX = "</body></html>";
+
+ /**
+ * Used to reset the {@link #mHtmlDocument} buffer so that the
+ * {@link #HTML_PREFIX} need not be appended all the time.
+ */
+ private static final int HTML_PREFIX_LENGTH = HTML_PREFIX.length();
+
+ private static final W3CDom W3C_DOM = new W3CDom();
+ private static final XhtmlNamespaceHandler NS_HANDLER =
+ new XhtmlNamespaceHandler();
+
+ /**
+ * The buffer is reused so that previous memory allocations need not repeat.
+ */
+ private final StringBuilder mHtmlDocument = new StringBuilder( 65536 );
+
+ private final HTMLPanel mHtmlRenderer = new HTMLPanel();
+ private final JScrollPane mScrollPane = new JScrollPane( mHtmlRenderer );
+ private final CustomImageLoader mImageLoader = new CustomImageLoader();
+
+ private Path mPath = DEFAULT_DIRECTORY;
+
+ /**
+ * Creates a new preview pane that can scroll to the caret position within the
+ * document.
+ */
+ public HTMLPreviewPane() {
+ setStyle( "-fx-background-color: white;" );
+
+ // No need to append same prefix each time the HTML content is updated.
+ mHtmlDocument.append( HTML_PREFIX );
+
+ // Inject an SVG renderer that produces high-quality SVG buffered images.
+ final var factory = new ChainedReplacedElementFactory();
+ factory.addFactory( new SvgReplacedElementFactory() );
+ factory.addFactory( new SwingReplacedElementFactory(
+ NO_OP_REPAINT_LISTENER, mImageLoader ) );
+
+ final var context = getSharedContext();
+ final var textRenderer = context.getTextRenderer();
+ context.setReplacedElementFactory( factory );
+ textRenderer.setSmoothingThreshold( 0 );
+
+ setContent( mScrollPane );
+ mHtmlRenderer.addDocumentListener( new DocumentEventHandler() );
+ mHtmlRenderer.addComponentListener( new ResizeListener() );
+
+ // 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.
+ for( final var listener : mHtmlRenderer.getMouseTrackingListeners() ) {
+ if( !(listener instanceof HoverListener) ) {
+ mHtmlRenderer.removeMouseTrackingListener( (FSMouseListener) listener );
+ }
+ }
+
+ mHtmlRenderer.addMouseTrackingListener( new HyperlinkListener() );
+ }
+
+ /**
+ * Updates the internal HTML source, loads it into the preview pane, then
+ * scrolls to the caret position.
+ *
+ * @param html The new HTML document to display.
+ */
+ public void process( final String html ) {
+ final var docJsoup = Jsoup.parse( decorate( html ) );
+ final var docW3c = W3C_DOM.fromJsoup( docJsoup );
+
+ // Access to a Swing component must occur from the Event Dispatch
+ // Thread (EDT) according to Swing threading restrictions.
+ invokeLater(
+ () -> mHtmlRenderer.setDocument( docW3c, getBaseUrl(), NS_HANDLER )
+ );
+ }
+
+ /**
+ * Clears the preview pane by rendering an empty string.
+ */
+ public void clear() {
+ process( "" );
+ }
+
+ /**
+ * Scrolls to the closest element matching the given identifier without
+ * waiting for the document to be ready. Be sure the document is ready
+ * before calling this method.
+ *
+ * @param id Scroll the preview pane to this unique paragraph identifier.
+ */
+ public void scrollTo( final String id ) {
+ scrollTo( getBoxById( id ) );
+ }
+
+ private void scrollTo( final Box box ) {
+ scrollTo( box == null ? POINT_TOP : createPoint( box ) );
+ }
+
+ private void scrollTo( final Point point ) {
+ invokeLater( () -> mHtmlRenderer.scrollTo( point ) );
+ }
+
+ private Box getBoxById( final String id ) {
+ return getSharedContext().getBoxById( id );
+ }
+
+ private String decorate( final String html ) {
+ // Trim the HTML back to only the prefix.
+ mHtmlDocument.setLength( HTML_PREFIX_LENGTH );
+
+ // Write the HTML body element followed by closing tags.
+ return mHtmlDocument.append( html ).append( HTML_SUFFIX ).toString();
+ }
+
+ public Path getPath() {
+ return mPath;
+ }
+
+ public void setPath( final Path path ) {
+ assert path != null;
+ mPath = path;
+ }
+
+ /**
+ * Content to embed in a panel.
+ *
+ * @return The content to display to the user.
+ */
+ public Node getNode() {
+ return this;
+ }
+
+ public void repaintScrollPane() {
+ invokeLater( () -> getScrollPane().repaint() );
+ }
+
+ public JScrollBar getVerticalScrollBar() {
+ return getScrollPane().getVerticalScrollBar();
+ }
+
+ /**
+ * 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;
+
+ int x = box.getAbsX();
+
+ // 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() - (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 JScrollPane getScrollPane() {
+ return mScrollPane;
+ }
+
+ private String getBaseUrl() {
+ final var basePath = getPath();
+ final var parent = basePath == null ? null : basePath.getParent();
return parent == null ? "" : parent.toUri().toString();
src/main/java/com/keenwrite/processors/IdentityProcessor.java
*/
public class IdentityProcessor extends AbstractProcessor<String> {
+ public static final IdentityProcessor INSTANCE = new IdentityProcessor();
/**
- * Constructs a new instance having no successor.
+ * Constructs a new instance having no successor (the default successor is
+ * {@code null}).
*/
- public IdentityProcessor() {
- super( null );
+ private IdentityProcessor() {
}
src/main/java/com/keenwrite/processors/InlineRProcessor.java
import static com.keenwrite.Constants.STATUS_PARSE_ERROR;
-import static com.keenwrite.StatusBarNotifier.clue;
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
import static com.keenwrite.sigils.RSigilOperator.PREFIX;
src/main/java/com/keenwrite/processors/ProcessorContext.java
import com.keenwrite.ExportFormat;
+import com.keenwrite.FileEditorTab;
import com.keenwrite.FileType;
import com.keenwrite.preview.HTMLPreviewPane;
+import com.keenwrite.processors.markdown.CaretPosition;
import java.nio.file.Path;
private final Map<String, String> mResolvedMap;
private final ExportFormat mExportFormat;
- private final FileType mFileType;
- private final Path mPath;
-
+ private final FileEditorTab mTab;
/**
* Creates a new context for use by the {@link ProcessorFactory} when
* instantiating new {@link Processor} instances. Although all the
* parameters are required, not all {@link Processor} instances will use
* all parameters.
*
* @param previewPane Where to display the final (HTML) output.
* @param resolvedMap Fully expanded interpolated strings.
- * @param path Path to the document to process.
+ * @param tab Tab containing path to the document to process.
* @param format Indicate configuration options for export format.
*/
public ProcessorContext(
final HTMLPreviewPane previewPane,
final Map<String, String> resolvedMap,
- final Path path,
+ final FileEditorTab tab,
final ExportFormat format ) {
+ assert previewPane != null;
+ assert resolvedMap != null;
+ assert tab != null;
+ assert format != null;
+
mPreviewPane = previewPane;
mResolvedMap = resolvedMap;
- mPath = path;
- mFileType = lookup( path );
+ mTab = tab;
mExportFormat = format;
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ boolean isExportFormat( final ExportFormat format ) {
+ return mExportFormat == format;
}
}
- public Path getPath() {
- return mPath;
+ public ExportFormat getExportFormat() {
+ return mExportFormat;
}
- FileType getFileType() {
- return mFileType;
+ /**
+ * Returns the current caret position in the document being edited and is
+ * always up-to-date.
+ *
+ * @return Caret position in the document.
+ */
+ public CaretPosition getCaretPosition() {
+ return mTab.getCaretPosition();
}
- public ExportFormat getExportFormat() {
- return mExportFormat;
+ public Path getPath() {
+ return mTab.getPath();
}
- @SuppressWarnings("SameParameterValue")
- boolean isExportFormat( final ExportFormat format ) {
- return mExportFormat == format;
+ FileType getFileType() {
+ return lookup( getPath() );
}
}
src/main/java/com/keenwrite/processors/ProcessorFactory.java
private final ProcessorContext mProcessorContext;
- private final Processor<String> mMarkdownProcessor;
/**
* Constructs a factory with the ability to create processors that can perform
* text and caret processing to generate a final preview.
*
* @param processorContext Parameters needed to construct various processors.
*/
private ProcessorFactory( final ProcessorContext processorContext ) {
mProcessorContext = processorContext;
- mMarkdownProcessor = createMarkdownProcessor();
}
// math (such as using the JavaScript-based KaTeX engine).
final Processor<String> successor = context.isExportFormat( NONE )
- ? getCommonProcessor()
- :
- switch( context.getExportFormat() ) {
- case HTML_TEX_SVG, HTML_TEX_DELIMITED -> createHtmlProcessor();
- case MARKDOWN_PLAIN, NONE -> createIdentityProcessor();
- };
+ ? createHtmlPreviewProcessor()
+ : createIdentityProcessor();
return switch( context.getFileType() ) {
case RMARKDOWN -> createRProcessor( successor );
- case SOURCE -> createMarkdownDefinitionProcessor( successor );
+ case SOURCE -> createMarkdownProcessor( successor );
case RXML -> createRXMLProcessor( successor );
case XML -> createXMLProcessor( successor );
*/
private Processor<String> createIdentityProcessor() {
- return new IdentityProcessor();
+ return IdentityProcessor.INSTANCE;
}
/**
* Instantiates a new {@link Processor} that passes an incoming HTML
* string to a user interface widget that can render HTML as a web page.
*
* @return An instance of {@link Processor} that forwards HTML for display.
*/
- private Processor<String> createHTMLPreviewProcessor() {
+ private Processor<String> createHtmlPreviewProcessor() {
return new HtmlPreviewProcessor( getPreviewPane() );
}
/**
- * Instantiates {@link Processor} instances that end the processing chain.
+ * Instantiates a {@link Processor} responsible for parsing Markdown and
+ * definitions.
*
- * @return A chain of {@link Processor}s that convert Markdown to HTML.
+ * @return A chain of {@link Processor}s for processing Markdown and
+ * definitions.
*/
- private Processor<String> createMarkdownProcessor() {
- final var hpp = createHTMLPreviewProcessor();
- return MarkdownProcessor.create( hpp, getProcessorContext() );
+ private Processor<String> createMarkdownProcessor(
+ final Processor<String> successor ) {
+ final var dpp = createDefinitionProcessor( successor );
+ return MarkdownProcessor.create( dpp, getProcessorContext() );
}
private Processor<String> createPreformattedProcessor(
final Processor<String> successor ) {
return new PreformattedProcessor( successor );
}
private Processor<String> createPreformattedProcessor() {
- return createPreformattedProcessor( createHTMLPreviewProcessor() );
+ return createPreformattedProcessor( createHtmlPreviewProcessor() );
}
final var rp = new InlineRProcessor( successor, getResolvedMap() );
return new RVariableProcessor( rp, getResolvedMap() );
- }
-
- private Processor<String> createMarkdownDefinitionProcessor(
- final Processor<String> successor ) {
- return createDefinitionProcessor( successor );
}
final var xmlp = new XmlProcessor( successor, getPath() );
return createDefinitionProcessor( xmlp );
- }
-
- private Processor<String> createHtmlProcessor() {
- final Processor<String> successor = createIdentityProcessor();
- return MarkdownProcessor.create( successor, getProcessorContext() );
- }
-
- /**
- * Returns the {@link Processor} common to all {@link Processor}s: markdown
- * and an HTML preview renderer.
- *
- * @return {@link Processor}s at the end of the processing chain.
- */
- private Processor<String> getCommonProcessor() {
- return mMarkdownProcessor;
}
src/main/java/com/keenwrite/processors/markdown/BlockExtension.java
-package com.keenwrite.processors.markdown;
-
-import com.vladsch.flexmark.ast.*;
-import com.vladsch.flexmark.html.AttributeProvider;
-import com.vladsch.flexmark.html.AttributeProviderFactory;
-import com.vladsch.flexmark.html.IndependentAttributeProviderFactory;
-import com.vladsch.flexmark.html.renderer.AttributablePart;
-import com.vladsch.flexmark.html.renderer.LinkResolverContext;
-import com.vladsch.flexmark.util.ast.Block;
-import com.vladsch.flexmark.util.ast.Node;
-import com.vladsch.flexmark.util.data.MutableDataHolder;
-import com.vladsch.flexmark.util.html.MutableAttributes;
-import org.jetbrains.annotations.NotNull;
-
-import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
-import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
-import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT;
-
-/**
- * Responsible for giving most block-level elements a unique identifier
- * attribute. The identifier is used to coordinate scrolling.
- */
-public class BlockExtension implements HtmlRendererExtension {
- /**
- * Responsible for creating the id attribute. This class is instantiated
- * each time the document is rendered, thereby resetting the count to zero.
- */
- public static class IdAttributeProvider implements AttributeProvider {
- private int mCount;
-
- private static AttributeProviderFactory createFactory() {
- return new IndependentAttributeProviderFactory() {
- @Override
- public @NotNull AttributeProvider apply(
- @NotNull final LinkResolverContext context ) {
- return new IdAttributeProvider();
- }
- };
- }
-
- @Override
- public void setAttributes( @NotNull Node node,
- @NotNull AttributablePart part,
- @NotNull MutableAttributes attributes ) {
- // Blockquotes are troublesome because they can interleave blank lines
- // without having an equivalent blank line in the source document. That
- // is, in Markdown the > symbol on a line by itself will generate a blank
- // line in the resulting document; however, a > symbol in the text editor
- // does not count as a blank line. Resolving this issue is tricky.
- //
- // The CODE_CONTENT represents <code> embedded inside <pre>; both elements
- // enter this method as FencedCodeBlock, but only the <pre> must be
- // uniquely identified (because they are the same line in Markdown).
- //
- if( node instanceof Block &&
- !(node instanceof BlockQuote) &&
- !(node instanceof ListBlock) &&
- !(node instanceof Paragraph && (node.getParent() instanceof ListItem) && node.getPrevious() == null) &&
- !(node instanceof FencedCodeBlock && (node.getParent() instanceof ListItem)) &&
- (part != CODE_CONTENT) ) {
- attributes.addValue( "id", Integer.toString( mCount++ ) );
- }
- }
- }
-
- private BlockExtension() {
- }
-
- @Override
- public void extend( final Builder builder,
- @NotNull final String rendererType ) {
- builder.attributeProviderFactory( IdAttributeProvider.createFactory() );
- }
-
- public static BlockExtension create() {
- return new BlockExtension();
- }
-
- @Override
- public void rendererOptions( @NotNull final MutableDataHolder options ) {
- }
-}
src/main/java/com/keenwrite/processors/markdown/CaretExtension.java
+/*
+ * Copyright 2020 White Magic Software, Ltd.
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * o Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * o Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.keenwrite.processors.markdown;
+
+import com.vladsch.flexmark.html.AttributeProvider;
+import com.vladsch.flexmark.html.AttributeProviderFactory;
+import com.vladsch.flexmark.html.IndependentAttributeProviderFactory;
+import com.vladsch.flexmark.html.renderer.AttributablePart;
+import com.vladsch.flexmark.html.renderer.LinkResolverContext;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
+import com.vladsch.flexmark.util.html.AttributeImpl;
+import com.vladsch.flexmark.util.html.MutableAttributes;
+import org.jetbrains.annotations.NotNull;
+
+import static com.keenwrite.Constants.CARET_ID;
+import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
+import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
+
+/**
+ * Responsible for giving most block-level elements a unique identifier
+ * attribute. The identifier is used to coordinate scrolling.
+ */
+public class CaretExtension implements HtmlRendererExtension {
+
+ /**
+ * Responsible for creating the id attribute. This class is instantiated
+ * each time the document is rendered, thereby resetting the count to zero.
+ */
+ public static class IdAttributeProvider implements AttributeProvider {
+ private final CaretPosition mCaret;
+
+ public IdAttributeProvider( final CaretPosition caret ) {
+ mCaret = caret;
+ }
+
+ private static AttributeProviderFactory createFactory(
+ final CaretPosition caret ) {
+ return new IndependentAttributeProviderFactory() {
+ @Override
+ public @NotNull AttributeProvider apply(
+ @NotNull final LinkResolverContext context ) {
+ return new IdAttributeProvider( caret );
+ }
+ };
+ }
+
+ @Override
+ public void setAttributes( @NotNull Node curr,
+ @NotNull AttributablePart part,
+ @NotNull MutableAttributes attributes ) {
+ final var began = curr.getStartOffset();
+ final var ended = curr.getEndOffset();
+ final var prev = curr.getPrevious();
+
+ // If the caret is within the bounds of the current node or the
+ // caret is within the bounds of the end of the previous node and
+ // the start of the current node, then mark the current node with
+ // a caret indicator.
+ if( mCaret.isBetweenText( began, ended ) ||
+ prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) {
+ attributes.addValue( AttributeImpl.of( "id", CARET_ID ) );
+ }
+ }
+ }
+
+ private final CaretPosition mCaret;
+
+ private CaretExtension( final CaretPosition caret ) {
+ mCaret = caret;
+ }
+
+ @Override
+ public void extend(
+ final Builder builder, @NotNull final String rendererType ) {
+ builder.attributeProviderFactory(
+ IdAttributeProvider.createFactory( mCaret ) );
+ }
+
+ public static CaretExtension create( final CaretPosition caret ) {
+ return new CaretExtension( caret );
+ }
+
+ @Override
+ public void rendererOptions( @NotNull final MutableDataHolder options ) {
+ }
+}
src/main/java/com/keenwrite/processors/markdown/CaretPosition.java
+/*
+ * Copyright 2020 White Magic Software, Ltd.
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * o Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * o Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.keenwrite.processors.markdown;
+
+import com.keenwrite.util.GenericBuilder;
+import javafx.beans.value.ObservableValue;
+import org.fxmisc.richtext.model.Paragraph;
+import org.reactfx.collection.LiveList;
+
+import java.util.Collection;
+
+import static com.keenwrite.Constants.STATUS_BAR_LINE;
+import static com.keenwrite.Messages.get;
+
+/**
+ * Represents the absolute, relative, and maximum position of the caret.
+ * The caret position is a character offset into the text.
+ */
+public class CaretPosition {
+
+ public static GenericBuilder<CaretPosition.Mutator, CaretPosition> builder() {
+ return GenericBuilder.of( CaretPosition.Mutator::new, CaretPosition::new );
+ }
+
+ public static class Mutator {
+ /**
+ * Caret's current paragraph index (i.e., current caret line number).
+ */
+ private ObservableValue<Integer> mParagraph;
+
+ private LiveList<Paragraph<Collection<String>, String,
+ Collection<String>>> mParagraphs;
+
+ /**
+ * Caret offset into the full text, represented as a string index.
+ */
+ private ObservableValue<Integer> mTextOffset;
+
+ /**
+ * Caret offset into the current paragraph, represented as a string index.
+ */
+ private ObservableValue<Integer> mParaOffset;
+
+ public void setParagraph( final ObservableValue<Integer> paragraph ) {
+ mParagraph = paragraph;
+ }
+
+ public void setParagraphs(
+ final LiveList<Paragraph<Collection<String>, String,
+ Collection<String>>> paragraphs ) {
+ mParagraphs = paragraphs;
+ }
+
+ public void setTextOffset( final ObservableValue<Integer> textOffset ) {
+ mTextOffset = textOffset;
+ }
+
+ public void setParaOffset( final ObservableValue<Integer> paraOffset ) {
+ mParaOffset = paraOffset;
+ }
+ }
+
+ private final Mutator mMutator;
+
+ /**
+ * Force using the builder pattern.
+ */
+ private CaretPosition( final Mutator mutator ) {
+ mMutator = mutator;
+ }
+
+ /**
+ * Answers whether the caret's offset into the text is between the given
+ * offsets.
+ *
+ * @param began Starting value compared against the caret's text offset.
+ * @param ended Ending value compared against the caret's text offset.
+ * @return {@code true} when the caret's text offset is between the given
+ * values, inclusively (for either value).
+ */
+ public boolean isBetweenText( final int began, final int ended ) {
+ final int offset = getTextOffset();
+ return began <= offset && offset <= ended;
+ }
+
+ /**
+ * Answers whether the caret's offset into the paragraph is before the given
+ * offset.
+ *
+ * @param offset Compared against the caret's paragraph offset.
+ * @return {@code true} the caret's offset is before the given offset.
+ */
+ public boolean isBeforeColumn( final int offset ) {
+ return getParaOffset() < offset;
+ }
+
+ /**
+ * Answers whether the caret's offset into the text is before the given
+ * text offset.
+ *
+ * @param offset Compared against the caret's text offset.
+ * @return {@code true} the caret's offset is after the given offset.
+ */
+ public boolean isAfterColumn( final int offset ) {
+ return getParaOffset() > offset;
+ }
+
+ private int getParagraph() {
+ return mMutator.mParagraph.getValue();
+ }
+
+ private int getParagraphCount() {
+ return mMutator.mParagraphs.size() + 1;
+ }
+
+ private int getTextOffset() {
+ return mMutator.mTextOffset.getValue();
+ }
+
+ private int getParaOffset() {
+ return mMutator.mParaOffset.getValue();
+ }
+
+ /**
+ * Returns a human-readable string that shows the current caret position
+ * within the text. Typically this will include the current line number,
+ * the number of lines, and the character offset into the text.
+ *
+ * @return A string to present to an end user.
+ */
+ @Override
+ public String toString() {
+ return get( STATUS_BAR_LINE,
+ getParagraph(),
+ getParagraphCount(),
+ getTextOffset() );
+ }
+}
src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
import com.keenwrite.ExportFormat;
import com.keenwrite.processors.AbstractProcessor;
+import com.keenwrite.processors.IdentityProcessor;
import com.keenwrite.processors.Processor;
import com.keenwrite.processors.ProcessorContext;
public static MarkdownProcessor create() {
- return create( null, Path.of( USER_DIRECTORY ) );
+ return create( IdentityProcessor.INSTANCE, Path.of( USER_DIRECTORY ) );
}
public static MarkdownProcessor create(
final Processor<String> successor, final Path path ) {
final var extensions = createExtensions( path, NONE );
-
return new MarkdownProcessor( successor, extensions );
}
public static MarkdownProcessor create(
final Processor<String> successor, final ProcessorContext context ) {
final var extensions = createExtensions( context );
return new MarkdownProcessor( successor, extensions );
}
+ /**
+ * Creating extensions based using an instance of {@link ProcessorContext}
+ * indicates that the {@link CaretExtension} should be used to inject the
+ * caret position into the final HTML document. This enables the HTML
+ * preview pane to scroll to the same position, relatively speaking, within
+ * the main document. Scrolling is developed this way to decouple the
+ * document being edited from the preview pane so that multiple document
+ * formats can be edited.
+ *
+ * @param context Contains necessary information needed to create extensions
+ * used by the Markdown parser.
+ * @return {@link Collection} of extensions invoked when parsing Markdown.
+ */
private static Collection<Extension> createExtensions(
final ProcessorContext context ) {
- return createExtensions( context.getPath(), context.getExportFormat() );
+ final var path = context.getPath();
+ final var format = context.getExportFormat();
+ final var extensions = createExtensions( path, format );
+
+ extensions.add( CaretExtension.create( context.getCaretPosition() ) );
+
+ return extensions;
}
+ /**
+ * Creates extensions for images and TeX.
+ *
+ * @param path Path name for referencing image files via relative paths
+ * and dynamic file types.
+ * @param format TeX export format to use when generating HTMl documents.
+ * @return {@link Collection} of extensions invoked when parsing Markdown.
+ */
private static Collection<Extension> createExtensions(
final Path path, final ExportFormat format ) {
final var extensions = createDefaultExtensions();
- // Allows referencing image files via relative paths and dynamic file types.
extensions.add( ImageLinkExtension.create( path ) );
- extensions.add( BlockExtension.create() );
extensions.add( TeXExtension.create( format ) );
src/main/java/com/keenwrite/util/Action.java
package com.keenwrite.util;
+import com.keenwrite.Messages;
+import de.jensd.fx.glyphs.GlyphIcons;
+import javafx.beans.value.ObservableBooleanValue;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.control.MenuItem;
/**
* Represents a menu action that can generate {@link MenuItem} instances and
* and {@link Node} instances for a toolbar.
*/
public abstract class Action {
+ /**
+ * TODO: Reuse the {@link GenericBuilder}.
+ *
+ * @return The {@link Builder} for an instance of {@link Action}.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
public abstract MenuItem createMenuItem();
*/
public void addSubActions( Action... action ) {
+ }
+
+ /**
+ * Provides a fluent interface around constructing actions so that duplication
+ * can be avoided.
+ */
+ public static class Builder {
+ private String mText;
+ private String mAccelerator;
+ private GlyphIcons mIcon;
+ private EventHandler<ActionEvent> mAction;
+ private ObservableBooleanValue mDisabled;
+
+ /**
+ * Sets the action text based on a resource bundle key.
+ *
+ * @param key The key to look up in the {@link Messages}.
+ * @return The corresponding value, or the key name if none found.
+ */
+ public Builder setText( final String key ) {
+ mText = Messages.get( key, key );
+ return this;
+ }
+
+ public Builder setAccelerator( final String accelerator ) {
+ mAccelerator = accelerator;
+ return this;
+ }
+
+ public Builder setIcon( final GlyphIcons icon ) {
+ mIcon = icon;
+ return this;
+ }
+
+ public Builder setAction( final EventHandler<ActionEvent> action ) {
+ mAction = action;
+ return this;
+ }
+
+ public Builder setDisabled( final ObservableBooleanValue disabled ) {
+ mDisabled = disabled;
+ return this;
+ }
+
+ public Action build() {
+ return new MenuAction( mText, mAccelerator, mIcon, mAction, mDisabled );
+ }
}
}
src/main/java/com/keenwrite/util/ActionBuilder.java
-/*
- * Copyright 2020 White Magic Software, Ltd.
- *
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- * o Redistributions of source code must retain the above copyright
- * notice, this list of conditions and the following disclaimer.
- *
- * o Redistributions in binary form must reproduce the above copyright
- * notice, this list of conditions and the following disclaimer in the
- * documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
- * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
- * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
- * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
- * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
- * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
- * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
- * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
- * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
- * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
- * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-package com.keenwrite.util;
-
-import com.keenwrite.Messages;
-import de.jensd.fx.glyphs.GlyphIcons;
-import javafx.beans.value.ObservableBooleanValue;
-import javafx.event.ActionEvent;
-import javafx.event.EventHandler;
-
-/**
- * Provides a fluent interface around constructing actions so that duplication
- * can be avoided.
- */
-public class ActionBuilder {
- private String mText;
- private String mAccelerator;
- private GlyphIcons mIcon;
- private EventHandler<ActionEvent> mAction;
- private ObservableBooleanValue mDisable;
-
- /**
- * Sets the action text based on a resource bundle key.
- *
- * @param key The key to look up in the {@link Messages}.
- * @return The corresponding value, or the key name if none found.
- */
- public ActionBuilder setText( final String key ) {
- mText = Messages.get( key, key );
- return this;
- }
-
- public ActionBuilder setAccelerator( final String accelerator ) {
- mAccelerator = accelerator;
- return this;
- }
-
- public ActionBuilder setIcon( final GlyphIcons icon ) {
- mIcon = icon;
- return this;
- }
-
- public ActionBuilder setAction( final EventHandler<ActionEvent> action ) {
- mAction = action;
- return this;
- }
-
- public ActionBuilder setDisable( final ObservableBooleanValue disable ) {
- mDisable = disable;
- return this;
- }
-
- public Action build() {
- return new MenuAction( mText, mAccelerator, mIcon, mAction, mDisable );
- }
-}
src/main/java/com/keenwrite/util/GenericBuilder.java
+package com.keenwrite.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Responsible for constructing objects that would otherwise require
+ * a long list of constructor parameters.
+ * <p>
+ * See <a href="https://stackoverflow.com/a/31754787/59087">source</a> for
+ * details.
+ * </p>
+ *
+ * @param <MT> The mutable definition for the type of object to build.
+ * @param <IT> The immutable definition for the type of object to build.
+ */
+public class GenericBuilder<MT, IT> {
+ /**
+ * Provides the methods to use for setting object properties.
+ */
+ private final Supplier<MT> mMutable;
+
+ /**
+ * Calling {@link #build()} will instantiate the immutable instance using
+ * the mutator.
+ */
+ private final Function<MT, IT> mImmutable;
+
+ /**
+ * Adds a modifier to call when building an instance.
+ */
+ private final List<Consumer<MT>> mModifiers = new ArrayList<>();
+
+ /**
+ * Constructs a new builder instance that is capable of populating values for
+ * any type of object.
+ *
+ * @param mutator Provides methods to use for setting object properties.
+ */
+ protected GenericBuilder(
+ final Supplier<MT> mutator, final Function<MT, IT> immutable ) {
+ mMutable = mutator;
+ mImmutable = immutable;
+ }
+
+ /**
+ * Starting point for building an instance of a particular class.
+ *
+ * @param supplier Returns the instance to build.
+ * @param <MT> The type of class to build.
+ * @return A new {@link GenericBuilder} capable of populating data for an
+ * instance of the class provided by the {@link Supplier}.
+ */
+ public static <MT, IT> GenericBuilder<MT, IT> of(
+ final Supplier<MT> supplier, final Function<MT, IT> immutable ) {
+ return new GenericBuilder<>( supplier, immutable );
+ }
+
+ /**
+ * Registers a new value with the builder.
+ *
+ * @param consumer Accepts a value to be set upon the built object.
+ * @param value The value to use when building.
+ * @param <V> The type of value used when building.
+ * @return This {@link GenericBuilder} instance.
+ */
+ public <V> GenericBuilder<MT, IT> with(
+ final BiConsumer<MT, V> consumer, final V value ) {
+ mModifiers.add( instance -> consumer.accept( instance, value ) );
+ return this;
+ }
+
+ /**
+ * Instantiates then populates the immutable object to build.
+ *
+ * @return The newly built object.
+ */
+ public IT build() {
+ final var value = mMutable.get();
+ mModifiers.forEach( modifier -> modifier.accept( value ) );
+ mModifiers.clear();
+ return mImmutable.apply( value );
+ }
+}
src/main/resources/com/keenwrite/preview/webview.css
}
-/* BLOCKS ***/
+#caret {
+ background: #fcfeff;
+}
+
p, blockquote, ul, ol, dl, table, pre {
margin: 1em 0;
}
-/* HEADINGS ***/
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
}
-/* CODE ***/
pre, code, tt {
/* Must be bundled in JAR file. */
pre > code {
- /* Reset the padding. */
padding: 0;
border: none;
background: transparent;
}
pre {
border: .125em solid #ccc;
overflow: auto;
- /* Assign the new padding, independently from previous. */
padding: .25em .5em;
}
pre code, pre tt {
background-color: transparent;
border: none;
}
-/* QUOTES ***/
blockquote {
border-left: .25em solid #ccc;
}
-/* HORIZONTAL RULES ***/
hr {
clear: both;
}
-/* TABLES ***/
table {
width: 100%;
}
-/* IMAGES ***/
img {
max-width: 100%;
Delta2452 lines added, 2287 lines removed, 165-line increase