Dave Jarvis' Repositories

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

Open Markdown editors in same pane

AuthorDaveJarvis <email>
Date2021-05-23 13:19:15 GMT-0700
Commit3c45f719c5d173699f3b51fa6bce02ed5fe13c03
Parent97e93df
Delta767 lines added, 750 lines removed, 17-line increase
src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
import com.keenwrite.constants.Constants;
import com.keenwrite.editors.TextEditor;
-import com.keenwrite.preferences.LocaleProperty;
-import com.keenwrite.preferences.Workspace;
-import com.keenwrite.spelling.impl.TextEditorSpeller;
-import javafx.beans.binding.Bindings;
-import javafx.beans.property.*;
-import javafx.beans.value.ChangeListener;
-import javafx.event.Event;
-import javafx.scene.Node;
-import javafx.scene.control.IndexRange;
-import javafx.scene.input.KeyEvent;
-import javafx.scene.layout.BorderPane;
-import org.fxmisc.flowless.VirtualizedScrollPane;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.richtext.model.StyleSpans;
-import org.fxmisc.undo.UndoManager;
-import org.fxmisc.wellbehaved.event.EventPattern;
-import org.fxmisc.wellbehaved.event.Nodes;
-
-import java.io.File;
-import java.nio.charset.Charset;
-import java.text.BreakIterator;
-import java.util.*;
-import java.util.function.Consumer;
-import java.util.function.Supplier;
-import java.util.regex.Pattern;
-
-import static com.keenwrite.constants.Constants.*;
-import static com.keenwrite.MainApp.keyDown;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus;
-import static com.keenwrite.preferences.WorkspaceKeys.*;
-import static java.lang.Character.isWhitespace;
-import static java.lang.String.format;
-import static java.util.Collections.singletonList;
-import static javafx.application.Platform.runLater;
-import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
-import static javafx.scene.input.KeyCode.*;
-import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
-import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
-import static org.apache.commons.lang3.StringUtils.stripEnd;
-import static org.apache.commons.lang3.StringUtils.stripStart;
-import static org.fxmisc.richtext.model.StyleSpans.singleton;
-import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
-import static org.fxmisc.wellbehaved.event.InputMap.consume;
-
-/**
- * Responsible for editing Markdown documents.
- */
-public final class MarkdownEditor extends BorderPane implements TextEditor {
- /**
- * Regular expression that matches the type of markup block. This is used
- * when Enter is pressed to continue the block environment.
- */
- private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
- "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
-
- /**
- * The text editor.
- */
- private final StyleClassedTextArea mTextArea =
- new StyleClassedTextArea( false );
-
- /**
- * Wraps the text editor in scrollbars.
- */
- private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
- new VirtualizedScrollPane<>( mTextArea );
-
- private final Workspace mWorkspace;
-
- /**
- * Tracks where the caret is located in this document. This offers observable
- * properties for caret position changes.
- */
- private final Caret mCaret = createCaret( mTextArea );
-
- /**
- * File being edited by this editor instance.
- */
- private File mFile;
-
- /**
- * Set to {@code true} upon text or caret position changes. Value is {@code
- * false} by default.
- */
- private final BooleanProperty mDirty = new SimpleBooleanProperty();
-
- /**
- * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
- * either no encoding could be determined or this is a new (empty) file.
- */
- private final Charset mEncoding;
-
- /**
- * Tracks whether the in-memory definitions have changed with respect to the
- * persisted definitions.
- */
- private final BooleanProperty mModified = new SimpleBooleanProperty();
-
- public MarkdownEditor( final Workspace workspace ) {
- this( DOCUMENT_DEFAULT, workspace );
- }
-
- public MarkdownEditor( final File file, final Workspace workspace ) {
- mEncoding = open( mFile = file );
- mWorkspace = workspace;
-
- initTextArea( mTextArea );
- initStyle( mTextArea );
- initScrollPane( mScrollPane );
- initSpellchecker( mTextArea );
- initHotKeys();
- initUndoManager();
- }
-
- private void initTextArea( final StyleClassedTextArea textArea ) {
- textArea.setWrapText( true );
- textArea.requestFollowCaret();
- textArea.moveTo( 0 );
-
- textArea.textProperty().addListener( ( c, o, n ) -> {
- // Fire, regardless of whether the caret position has changed.
- mDirty.set( false );
-
- // Prevent a caret position change from raising the dirty bits.
- mDirty.set( true );
- } );
-
- textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
- // Fire when the caret position has changed and the text has not.
- mDirty.set( true );
- mDirty.set( false );
- } );
-
- textArea.focusedProperty().addListener( ( c, o, n ) -> {
- if( n != null && n ) {
- fireTextEditorFocus( this );
- }
- } );
- }
-
- private void initStyle( final StyleClassedTextArea textArea ) {
- textArea.getStyleClass().add( "markdown" );
-
- final var stylesheets = textArea.getStylesheets();
- stylesheets.add( getStylesheetPath( getLocale() ) );
-
- localeProperty().addListener( ( c, o, n ) -> {
- if( n != null ) {
- stylesheets.clear();
- stylesheets.add( getStylesheetPath( getLocale() ) );
- }
- } );
-
- fontNameProperty().addListener(
- ( c, o, n ) ->
- setFont( mTextArea, getFontName(), getFontSize() )
- );
-
- fontSizeProperty().addListener(
- ( c, o, n ) ->
- setFont( mTextArea, getFontName(), getFontSize() )
- );
-
- setFont( mTextArea, getFontName(), getFontSize() );
- }
-
- private void initScrollPane(
- final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
- scrollpane.setVbarPolicy( ALWAYS );
- setCenter( scrollpane );
- }
-
- private void initSpellchecker( final StyleClassedTextArea textarea ) {
- final var speller = new TextEditorSpeller();
- speller.checkDocument( textarea );
- speller.checkParagraphs( textarea );
- }
-
- private void initHotKeys() {
- addEventListener( keyPressed( ENTER ), this::onEnterPressed );
- addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
- addEventListener( keyPressed( TAB ), this::tab );
- addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
- addEventListener( keyPressed( INSERT ), this::onInsertPressed );
- }
-
- private void initUndoManager() {
- final var undoManager = getUndoManager();
- final var markedPosition = undoManager.atMarkedPositionProperty();
-
- undoManager.forgetHistory();
- undoManager.mark();
- mModified.bind( Bindings.not( markedPosition ) );
- }
-
- @Override
- public void moveTo( final int offset ) {
- assert 0 <= offset && offset <= mTextArea.getLength();
- mTextArea.moveTo( offset );
- mTextArea.requestFollowCaret();
- }
-
- /**
- * Delegate the focus request to the text area itself.
- */
- @Override
- public void requestFocus() {
- mTextArea.requestFocus();
- }
-
- @Override
- public void setText( final String text ) {
- mTextArea.clear();
- mTextArea.appendText( text );
- mTextArea.getUndoManager().mark();
- }
-
- @Override
- public String getText() {
- return mTextArea.getText();
- }
-
- @Override
- public Charset getEncoding() {
- return mEncoding;
- }
-
- @Override
- public File getFile() {
- return mFile;
- }
-
- @Override
- public void rename( final File file ) {
- mFile = file;
- }
-
- @Override
- public void undo() {
- final var manager = getUndoManager();
- xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
- }
-
- @Override
- public void redo() {
- final var manager = getUndoManager();
- xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
- }
-
- /**
- * Performs an undo or redo action, if possible, otherwise displays an error
- * message to the user.
- *
- * @param ready Answers whether the action can be executed.
- * @param action The action to execute.
- * @param key The informational message key having a value to display if
- * the {@link Supplier} is not ready.
- */
- private void xxdo(
- final Supplier<Boolean> ready, final Runnable action, final String key ) {
- if( ready.get() ) {
- action.run();
- }
- else {
- clue( key );
- }
- }
-
- @Override
- public void cut() {
- final var selected = mTextArea.getSelectedText();
-
- // Emulate selecting the current line by firing Home then Shift+Down Arrow.
- if( selected == null || selected.isEmpty() ) {
- // Note: mTextArea.selectLine() does not select empty lines.
- mTextArea.fireEvent( keyDown( HOME, false ) );
- mTextArea.fireEvent( keyDown( DOWN, true ) );
- }
-
- mTextArea.cut();
- }
-
- @Override
- public void copy() {
- mTextArea.copy();
- }
-
- @Override
- public void paste() {
- mTextArea.paste();
- }
-
- @Override
- public void selectAll() {
- mTextArea.selectAll();
- }
-
- @Override
- public void bold() {
- enwrap( "**" );
- }
-
- @Override
- public void italic() {
- enwrap( "*" );
- }
-
- @Override
- public void monospace() {
- enwrap( "`" );
- }
-
- @Override
- public void superscript() {
- enwrap( "^" );
- }
-
- @Override
- public void subscript() {
- enwrap( "~" );
- }
-
- @Override
- public void strikethrough() {
- enwrap( "~~" );
- }
-
- @Override
- public void blockquote() {
- block( "> " );
- }
-
- @Override
- public void code() {
- enwrap( "`" );
- }
-
- @Override
- public void fencedCodeBlock() {
- enwrap( "\n\n```\n", "\n```\n\n" );
- }
-
- @Override
- public void heading( final int level ) {
- final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
- block( format( "%s ", hashes ) );
- }
-
- @Override
- public void unorderedList() {
- block( "* " );
- }
-
- @Override
- public void orderedList() {
- block( "1. " );
- }
-
- @Override
- public void horizontalRule() {
- block( format( "---%n%n" ) );
- }
-
- @Override
- public Node getNode() {
- return this;
- }
-
- @Override
- public ReadOnlyBooleanProperty modifiedProperty() {
- return mModified;
- }
-
- @Override
- public void clearModifiedProperty() {
- getUndoManager().mark();
- }
-
- @Override
- public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
- return mScrollPane;
- }
-
- @Override
- public StyleClassedTextArea getTextArea() {
- return mTextArea;
- }
-
- private final Map<String, IndexRange> mStyles = new HashMap<>();
-
- @Override
- public void stylize( final IndexRange range, final String style ) {
- final var began = range.getStart();
- final var ended = range.getEnd() + 1;
-
- assert 0 <= began && began <= ended;
- assert style != null;
-
- // TODO: Ensure spell check and find highlights can coexist.
-// final var spans = mTextArea.getStyleSpans( range );
-// System.out.println( "SPANS: " + spans );
-
-// final var spans = mTextArea.getStyleSpans( range );
-// mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
-// ) );
-
-// final var builder = new StyleSpansBuilder<Collection<String>>();
-// builder.add( singleton( style ), range.getLength() + 1 );
-// mTextArea.setStyleSpans( began, builder.create() );
-
-// final var s = mTextArea.getStyleSpans( began, ended );
-// System.out.println( "STYLES: " +s );
-
- mStyles.put( style, range );
- mTextArea.setStyleClass( began, ended, style );
-
- // Ensure that whenever the user interacts with the text that the found
- // word will have its highlighting removed. The handler removes itself.
- // This won't remove the highlighting if the caret position moves by mouse.
- final var handler = mTextArea.getOnKeyPressed();
- mTextArea.setOnKeyPressed( ( event ) -> {
- mTextArea.setOnKeyPressed( handler );
- unstylize( style );
- } );
-
- //mTextArea.setStyleSpans(began, ended, s);
- }
-
- private static StyleSpans<Collection<String>> merge(
- StyleSpans<Collection<String>> spans, int len, String style ) {
- spans = spans.overlay(
- singleton( singletonList( style ), len ),
- ( bottomSpan, list ) -> {
- final List<String> l =
- new ArrayList<>( bottomSpan.size() + list.size() );
- l.addAll( bottomSpan );
- l.addAll( list );
- return l;
- } );
-
- return spans;
- }
-
- @Override
- public void unstylize( final String style ) {
- final var indexes = mStyles.remove( style );
- if( indexes != null ) {
- mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
- }
- }
-
- @Override
- public Caret getCaret() {
- return mCaret;
- }
-
- private Caret createCaret( final StyleClassedTextArea editor ) {
- return Caret
- .builder()
- .with( Caret.Mutator::setEditor, editor )
- .build();
- }
-
- /**
- * This method adds listeners to editor events.
- *
- * @param <T> The event type.
- * @param <U> The consumer type for the given event type.
- * @param event The event of interest.
- * @param consumer The method to call when the event happens.
- */
- public <T extends Event, U extends T> void addEventListener(
- final EventPattern<? super T, ? extends U> event,
- final Consumer<? super U> consumer ) {
- Nodes.addInputMap( mTextArea, consume( event, consumer ) );
- }
-
- private void onEnterPressed( final KeyEvent ignored ) {
- final var currentLine = getCaretParagraph();
- final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
-
- // By default, insert a new line by itself.
- String newText = NEWLINE;
-
- // If the pattern was matched then determine what block type to continue.
- if( matcher.matches() ) {
- if( matcher.group( 2 ).isEmpty() ) {
- final var pos = mTextArea.getCaretPosition();
- mTextArea.selectRange( pos - currentLine.length(), pos );
- }
- else {
- // Indent the new line with the same whitespace characters and
- // list markers as current line. This ensures that the indentation
- // is propagated.
- newText = newText.concat( matcher.group( 1 ) );
- }
- }
-
- mTextArea.replaceSelection( newText );
- }
-
- /**
- * TODO: 105 - Insert key toggle overwrite (typeover) mode
- *
- * @param ignored Unused.
- */
- private void onInsertPressed( final KeyEvent ignored ) {
- }
-
- private void cut( final KeyEvent event ) {
- cut();
- }
-
- private void tab( final KeyEvent event ) {
- final var range = mTextArea.selectionProperty().getValue();
- final var sb = new StringBuilder( 1024 );
-
- if( range.getLength() > 0 ) {
- final var selection = mTextArea.getSelectedText();
-
- selection.lines().forEach(
- ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
- );
- }
- else {
- sb.append( "\t" );
- }
-
- mTextArea.replaceSelection( sb.toString() );
- }
-
- private void untab( final KeyEvent event ) {
- final var range = mTextArea.selectionProperty().getValue();
-
- if( range.getLength() > 0 ) {
- final var selection = mTextArea.getSelectedText();
- final var sb = new StringBuilder( selection.length() );
-
- selection.lines().forEach(
- ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
- .append( NEWLINE )
- );
-
- mTextArea.replaceSelection( sb.toString() );
- }
- else {
- final var p = getCaretParagraph();
-
- if( p.startsWith( "\t" ) ) {
- mTextArea.selectParagraph();
- mTextArea.replaceSelection( p.substring( 1 ) );
- }
- }
- }
-
- /**
- * Observers may listen for changes to the property returned from this method
- * to receive notifications when either the text or caret have changed. This
- * should not be used to track whether the text has been modified.
- */
- public void addDirtyListener( ChangeListener<Boolean> listener ) {
- mDirty.addListener( listener );
- }
-
- /**
- * Surrounds the selected text or word under the caret in Markdown markup.
- *
- * @param token The beginning and ending token for enclosing the text.
- */
- private void enwrap( final String token ) {
- enwrap( token, token );
- }
-
- /**
- * Surrounds the selected text or word under the caret in Markdown markup.
- *
- * @param began The beginning token for enclosing the text.
- * @param ended The ending token for enclosing the text.
- */
- private void enwrap( final String began, String ended ) {
- // Ensure selected text takes precedence over the word at caret position.
- final var selected = mTextArea.selectionProperty().getValue();
- final var range = selected.getLength() == 0
- ? getCaretWord()
- : selected;
- String text = mTextArea.getText( range );
-
- int length = range.getLength();
- text = stripStart( text, null );
- final int beganIndex = range.getStart() + (length - text.length());
-
- length = text.length();
- text = stripEnd( text, null );
- final int endedIndex = range.getEnd() - (length - text.length());
-
- mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
- }
-
- /**
- * Inserts the given block-level markup at the current caret position
- * within the document. This will prepend two blank lines to ensure that
- * the block element begins at the start of a new line.
- *
- * @param markup The text to insert at the caret.
- */
- private void block( final String markup ) {
- final int pos = mTextArea.getCaretPosition();
- mTextArea.insertText( pos, format( "%n%n%s", markup ) );
- }
-
- /**
- * Returns the caret position within the current paragraph.
- *
- * @return A value from 0 to the length of the current paragraph.
- */
- private int getCaretColumn() {
- return mTextArea.getCaretColumn();
- }
-
- @Override
- public IndexRange getCaretWord() {
- final var paragraph = getCaretParagraph();
- final var length = paragraph.length();
- final var column = getCaretColumn();
-
- var began = column;
- var ended = column;
-
- while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
- began--;
- }
-
- while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
- ended++;
- }
-
- final var iterator = BreakIterator.getWordInstance();
- iterator.setText( paragraph );
-
- while( began < length && iterator.isBoundary( began + 1 ) ) {
- began++;
- }
-
- while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
- ended--;
- }
-
- final var offset = getCaretDocumentOffset( column );
-
- return IndexRange.normalize( began + offset, ended + offset );
- }
-
- private int getCaretDocumentOffset( final int column ) {
- return mTextArea.getCaretPosition() - column;
- }
-
- /**
- * Returns the index of the paragraph where the caret resides.
- *
- * @return A number greater than or equal to 0.
- */
- private int getCurrentParagraph() {
- return mTextArea.getCurrentParagraph();
- }
-
- /**
- * Returns the text for the paragraph that contains the caret.
- *
- * @return A non-null string, possibly empty.
- */
- private String getCaretParagraph() {
- return getText( getCurrentParagraph() );
- }
-
- @Override
- public String getText( final int paragraph ) {
- return mTextArea.getText( paragraph );
- }
-
- @Override
- public String getText( final IndexRange indexes )
- throws IndexOutOfBoundsException {
- return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
- }
-
- @Override
- public void replaceText( final IndexRange indexes, final String s ) {
- mTextArea.replaceText( indexes, s );
- }
-
- private UndoManager<?> getUndoManager() {
- return mTextArea.getUndoManager();
- }
-
- /**
- * Returns the path to a {@link Locale}-specific stylesheet.
- *
- * @return A non-null string to inject into the HTML document head.
- */
- private static String getStylesheetPath( final Locale locale ) {
- return get(
- sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
- locale.getLanguage(),
- locale.getScript(),
- locale.getCountry()
- );
- }
-
- private Locale getLocale() {
- return localeProperty().toLocale();
- }
-
- private LocaleProperty localeProperty() {
- return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
- }
-
- /**
- * Sets the font family name and font size at the same time. When the
- * workspace is loaded, the default font values are changed, which results
- * in this method being called.
- *
- * @param area Change the font settings for this text area.
- * @param name New font family name to apply.
- * @param points New font size to apply (in points, not pixels).
- */
- private void setFont(
- final StyleClassedTextArea area, final String name, final double points ) {
- runLater( () -> area.setStyle(
- format(
- "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points )
- )
- ) );
- }
-
- private String getFontName() {
- return fontNameProperty().get();
- }
-
- private StringProperty fontNameProperty() {
- return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
- }
-
- private double getFontSize() {
- return fontSizeProperty().get();
- }
-
- private DoubleProperty fontSizeProperty() {
- return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE );
+import com.keenwrite.io.MediaType;
+import com.keenwrite.preferences.LocaleProperty;
+import com.keenwrite.preferences.Workspace;
+import com.keenwrite.spelling.impl.TextEditorSpeller;
+import javafx.beans.binding.Bindings;
+import javafx.beans.property.*;
+import javafx.beans.value.ChangeListener;
+import javafx.event.Event;
+import javafx.scene.Node;
+import javafx.scene.control.IndexRange;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.BorderPane;
+import org.fxmisc.flowless.VirtualizedScrollPane;
+import org.fxmisc.richtext.StyleClassedTextArea;
+import org.fxmisc.richtext.model.StyleSpans;
+import org.fxmisc.undo.UndoManager;
+import org.fxmisc.wellbehaved.event.EventPattern;
+import org.fxmisc.wellbehaved.event.Nodes;
+
+import java.io.File;
+import java.nio.charset.Charset;
+import java.text.BreakIterator;
+import java.util.*;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import static com.keenwrite.MainApp.keyDown;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.Constants.*;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus;
+import static com.keenwrite.io.MediaType.*;
+import static com.keenwrite.preferences.WorkspaceKeys.*;
+import static java.lang.Character.isWhitespace;
+import static java.lang.String.format;
+import static java.util.Collections.singletonList;
+import static javafx.application.Platform.runLater;
+import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
+import static javafx.scene.input.KeyCode.*;
+import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
+import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
+import static org.apache.commons.lang3.StringUtils.stripEnd;
+import static org.apache.commons.lang3.StringUtils.stripStart;
+import static org.fxmisc.richtext.model.StyleSpans.singleton;
+import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
+import static org.fxmisc.wellbehaved.event.InputMap.consume;
+
+/**
+ * Responsible for editing Markdown documents.
+ */
+public final class MarkdownEditor extends BorderPane implements TextEditor {
+ /**
+ * Regular expression that matches the type of markup block. This is used
+ * when Enter is pressed to continue the block environment.
+ */
+ private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
+ "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
+
+ /**
+ * The text editor.
+ */
+ private final StyleClassedTextArea mTextArea =
+ new StyleClassedTextArea( false );
+
+ /**
+ * Wraps the text editor in scrollbars.
+ */
+ private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
+ new VirtualizedScrollPane<>( mTextArea );
+
+ private final Workspace mWorkspace;
+
+ /**
+ * Tracks where the caret is located in this document. This offers observable
+ * properties for caret position changes.
+ */
+ private final Caret mCaret = createCaret( mTextArea );
+
+ /**
+ * File being edited by this editor instance.
+ */
+ private File mFile;
+
+ /**
+ * Set to {@code true} upon text or caret position changes. Value is {@code
+ * false} by default.
+ */
+ private final BooleanProperty mDirty = new SimpleBooleanProperty();
+
+ /**
+ * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
+ * either no encoding could be determined or this is a new (empty) file.
+ */
+ private final Charset mEncoding;
+
+ /**
+ * Tracks whether the in-memory definitions have changed with respect to the
+ * persisted definitions.
+ */
+ private final BooleanProperty mModified = new SimpleBooleanProperty();
+
+ public MarkdownEditor( final Workspace workspace ) {
+ this( DOCUMENT_DEFAULT, workspace );
+ }
+
+ public MarkdownEditor( final File file, final Workspace workspace ) {
+ mEncoding = open( mFile = file );
+ mWorkspace = workspace;
+
+ initTextArea( mTextArea );
+ initStyle( mTextArea );
+ initScrollPane( mScrollPane );
+ initSpellchecker( mTextArea );
+ initHotKeys();
+ initUndoManager();
+ }
+
+ private void initTextArea( final StyleClassedTextArea textArea ) {
+ textArea.setWrapText( true );
+ textArea.requestFollowCaret();
+ textArea.moveTo( 0 );
+
+ textArea.textProperty().addListener( ( c, o, n ) -> {
+ // Fire, regardless of whether the caret position has changed.
+ mDirty.set( false );
+
+ // Prevent a caret position change from raising the dirty bits.
+ mDirty.set( true );
+ } );
+
+ textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
+ // Fire when the caret position has changed and the text has not.
+ mDirty.set( true );
+ mDirty.set( false );
+ } );
+
+ textArea.focusedProperty().addListener( ( c, o, n ) -> {
+ if( n != null && n ) {
+ fireTextEditorFocus( this );
+ }
+ } );
+ }
+
+ private void initStyle( final StyleClassedTextArea textArea ) {
+ textArea.getStyleClass().add( "markdown" );
+
+ final var stylesheets = textArea.getStylesheets();
+ stylesheets.add( getStylesheetPath( getLocale() ) );
+
+ localeProperty().addListener( ( c, o, n ) -> {
+ if( n != null ) {
+ stylesheets.clear();
+ stylesheets.add( getStylesheetPath( getLocale() ) );
+ }
+ } );
+
+ fontNameProperty().addListener(
+ ( c, o, n ) ->
+ setFont( mTextArea, getFontName(), getFontSize() )
+ );
+
+ fontSizeProperty().addListener(
+ ( c, o, n ) ->
+ setFont( mTextArea, getFontName(), getFontSize() )
+ );
+
+ setFont( mTextArea, getFontName(), getFontSize() );
+ }
+
+ private void initScrollPane(
+ final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
+ scrollpane.setVbarPolicy( ALWAYS );
+ setCenter( scrollpane );
+ }
+
+ private void initSpellchecker( final StyleClassedTextArea textarea ) {
+ final var speller = new TextEditorSpeller();
+ speller.checkDocument( textarea );
+ speller.checkParagraphs( textarea );
+ }
+
+ private void initHotKeys() {
+ addEventListener( keyPressed( ENTER ), this::onEnterPressed );
+ addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
+ addEventListener( keyPressed( TAB ), this::tab );
+ addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
+ addEventListener( keyPressed( INSERT ), this::onInsertPressed );
+ }
+
+ private void initUndoManager() {
+ final var undoManager = getUndoManager();
+ final var markedPosition = undoManager.atMarkedPositionProperty();
+
+ undoManager.forgetHistory();
+ undoManager.mark();
+ mModified.bind( Bindings.not( markedPosition ) );
+ }
+
+ @Override
+ public void moveTo( final int offset ) {
+ assert 0 <= offset && offset <= mTextArea.getLength();
+ mTextArea.moveTo( offset );
+ mTextArea.requestFollowCaret();
+ }
+
+ /**
+ * Delegate the focus request to the text area itself.
+ */
+ @Override
+ public void requestFocus() {
+ mTextArea.requestFocus();
+ }
+
+ @Override
+ public void setText( final String text ) {
+ mTextArea.clear();
+ mTextArea.appendText( text );
+ mTextArea.getUndoManager().mark();
+ }
+
+ @Override
+ public String getText() {
+ return mTextArea.getText();
+ }
+
+ @Override
+ public Charset getEncoding() {
+ return mEncoding;
+ }
+
+ @Override
+ public File getFile() {
+ return mFile;
+ }
+
+ @Override
+ public void rename( final File file ) {
+ mFile = file;
+ }
+
+ @Override
+ public void undo() {
+ final var manager = getUndoManager();
+ xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
+ }
+
+ @Override
+ public void redo() {
+ final var manager = getUndoManager();
+ xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
+ }
+
+ /**
+ * Performs an undo or redo action, if possible, otherwise displays an error
+ * message to the user.
+ *
+ * @param ready Answers whether the action can be executed.
+ * @param action The action to execute.
+ * @param key The informational message key having a value to display if
+ * the {@link Supplier} is not ready.
+ */
+ private void xxdo(
+ final Supplier<Boolean> ready, final Runnable action, final String key ) {
+ if( ready.get() ) {
+ action.run();
+ }
+ else {
+ clue( key );
+ }
+ }
+
+ @Override
+ public void cut() {
+ final var selected = mTextArea.getSelectedText();
+
+ // Emulate selecting the current line by firing Home then Shift+Down Arrow.
+ if( selected == null || selected.isEmpty() ) {
+ // Note: mTextArea.selectLine() does not select empty lines.
+ mTextArea.fireEvent( keyDown( HOME, false ) );
+ mTextArea.fireEvent( keyDown( DOWN, true ) );
+ }
+
+ mTextArea.cut();
+ }
+
+ @Override
+ public void copy() {
+ mTextArea.copy();
+ }
+
+ @Override
+ public void paste() {
+ mTextArea.paste();
+ }
+
+ @Override
+ public void selectAll() {
+ mTextArea.selectAll();
+ }
+
+ @Override
+ public void bold() {
+ enwrap( "**" );
+ }
+
+ @Override
+ public void italic() {
+ enwrap( "*" );
+ }
+
+ @Override
+ public void monospace() {
+ enwrap( "`" );
+ }
+
+ @Override
+ public void superscript() {
+ enwrap( "^" );
+ }
+
+ @Override
+ public void subscript() {
+ enwrap( "~" );
+ }
+
+ @Override
+ public void strikethrough() {
+ enwrap( "~~" );
+ }
+
+ @Override
+ public void blockquote() {
+ block( "> " );
+ }
+
+ @Override
+ public void code() {
+ enwrap( "`" );
+ }
+
+ @Override
+ public void fencedCodeBlock() {
+ enwrap( "\n\n```\n", "\n```\n\n" );
+ }
+
+ @Override
+ public void heading( final int level ) {
+ final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
+ block( format( "%s ", hashes ) );
+ }
+
+ @Override
+ public void unorderedList() {
+ block( "* " );
+ }
+
+ @Override
+ public void orderedList() {
+ block( "1. " );
+ }
+
+ @Override
+ public void horizontalRule() {
+ block( format( "---%n%n" ) );
+ }
+
+ @Override
+ public Node getNode() {
+ return this;
+ }
+
+ @Override
+ public ReadOnlyBooleanProperty modifiedProperty() {
+ return mModified;
+ }
+
+ @Override
+ public void clearModifiedProperty() {
+ getUndoManager().mark();
+ }
+
+ @Override
+ public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
+ return mScrollPane;
+ }
+
+ @Override
+ public StyleClassedTextArea getTextArea() {
+ return mTextArea;
+ }
+
+ private final Map<String, IndexRange> mStyles = new HashMap<>();
+
+ @Override
+ public void stylize( final IndexRange range, final String style ) {
+ final var began = range.getStart();
+ final var ended = range.getEnd() + 1;
+
+ assert 0 <= began && began <= ended;
+ assert style != null;
+
+ // TODO: Ensure spell check and find highlights can coexist.
+// final var spans = mTextArea.getStyleSpans( range );
+// System.out.println( "SPANS: " + spans );
+
+// final var spans = mTextArea.getStyleSpans( range );
+// mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
+// ) );
+
+// final var builder = new StyleSpansBuilder<Collection<String>>();
+// builder.add( singleton( style ), range.getLength() + 1 );
+// mTextArea.setStyleSpans( began, builder.create() );
+
+// final var s = mTextArea.getStyleSpans( began, ended );
+// System.out.println( "STYLES: " +s );
+
+ mStyles.put( style, range );
+ mTextArea.setStyleClass( began, ended, style );
+
+ // Ensure that whenever the user interacts with the text that the found
+ // word will have its highlighting removed. The handler removes itself.
+ // This won't remove the highlighting if the caret position moves by mouse.
+ final var handler = mTextArea.getOnKeyPressed();
+ mTextArea.setOnKeyPressed( ( event ) -> {
+ mTextArea.setOnKeyPressed( handler );
+ unstylize( style );
+ } );
+
+ //mTextArea.setStyleSpans(began, ended, s);
+ }
+
+ private static StyleSpans<Collection<String>> merge(
+ StyleSpans<Collection<String>> spans, int len, String style ) {
+ spans = spans.overlay(
+ singleton( singletonList( style ), len ),
+ ( bottomSpan, list ) -> {
+ final List<String> l =
+ new ArrayList<>( bottomSpan.size() + list.size() );
+ l.addAll( bottomSpan );
+ l.addAll( list );
+ return l;
+ } );
+
+ return spans;
+ }
+
+ @Override
+ public void unstylize( final String style ) {
+ final var indexes = mStyles.remove( style );
+ if( indexes != null ) {
+ mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
+ }
+ }
+
+ @Override
+ public Caret getCaret() {
+ return mCaret;
+ }
+
+ private Caret createCaret( final StyleClassedTextArea editor ) {
+ return Caret
+ .builder()
+ .with( Caret.Mutator::setEditor, editor )
+ .build();
+ }
+
+ /**
+ * This method adds listeners to editor events.
+ *
+ * @param <T> The event type.
+ * @param <U> The consumer type for the given event type.
+ * @param event The event of interest.
+ * @param consumer The method to call when the event happens.
+ */
+ public <T extends Event, U extends T> void addEventListener(
+ final EventPattern<? super T, ? extends U> event,
+ final Consumer<? super U> consumer ) {
+ Nodes.addInputMap( mTextArea, consume( event, consumer ) );
+ }
+
+ private void onEnterPressed( final KeyEvent ignored ) {
+ final var currentLine = getCaretParagraph();
+ final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
+
+ // By default, insert a new line by itself.
+ String newText = NEWLINE;
+
+ // If the pattern was matched then determine what block type to continue.
+ if( matcher.matches() ) {
+ if( matcher.group( 2 ).isEmpty() ) {
+ final var pos = mTextArea.getCaretPosition();
+ mTextArea.selectRange( pos - currentLine.length(), pos );
+ }
+ else {
+ // Indent the new line with the same whitespace characters and
+ // list markers as current line. This ensures that the indentation
+ // is propagated.
+ newText = newText.concat( matcher.group( 1 ) );
+ }
+ }
+
+ mTextArea.replaceSelection( newText );
+ }
+
+ /**
+ * TODO: 105 - Insert key toggle overwrite (typeover) mode
+ *
+ * @param ignored Unused.
+ */
+ private void onInsertPressed( final KeyEvent ignored ) {
+ }
+
+ private void cut( final KeyEvent event ) {
+ cut();
+ }
+
+ private void tab( final KeyEvent event ) {
+ final var range = mTextArea.selectionProperty().getValue();
+ final var sb = new StringBuilder( 1024 );
+
+ if( range.getLength() > 0 ) {
+ final var selection = mTextArea.getSelectedText();
+
+ selection.lines().forEach(
+ ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
+ );
+ }
+ else {
+ sb.append( "\t" );
+ }
+
+ mTextArea.replaceSelection( sb.toString() );
+ }
+
+ private void untab( final KeyEvent event ) {
+ final var range = mTextArea.selectionProperty().getValue();
+
+ if( range.getLength() > 0 ) {
+ final var selection = mTextArea.getSelectedText();
+ final var sb = new StringBuilder( selection.length() );
+
+ selection.lines().forEach(
+ ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
+ .append( NEWLINE )
+ );
+
+ mTextArea.replaceSelection( sb.toString() );
+ }
+ else {
+ final var p = getCaretParagraph();
+
+ if( p.startsWith( "\t" ) ) {
+ mTextArea.selectParagraph();
+ mTextArea.replaceSelection( p.substring( 1 ) );
+ }
+ }
+ }
+
+ /**
+ * Observers may listen for changes to the property returned from this method
+ * to receive notifications when either the text or caret have changed. This
+ * should not be used to track whether the text has been modified.
+ */
+ public void addDirtyListener( ChangeListener<Boolean> listener ) {
+ mDirty.addListener( listener );
+ }
+
+ /**
+ * Surrounds the selected text or word under the caret in Markdown markup.
+ *
+ * @param token The beginning and ending token for enclosing the text.
+ */
+ private void enwrap( final String token ) {
+ enwrap( token, token );
+ }
+
+ /**
+ * Surrounds the selected text or word under the caret in Markdown markup.
+ *
+ * @param began The beginning token for enclosing the text.
+ * @param ended The ending token for enclosing the text.
+ */
+ private void enwrap( final String began, String ended ) {
+ // Ensure selected text takes precedence over the word at caret position.
+ final var selected = mTextArea.selectionProperty().getValue();
+ final var range = selected.getLength() == 0
+ ? getCaretWord()
+ : selected;
+ String text = mTextArea.getText( range );
+
+ int length = range.getLength();
+ text = stripStart( text, null );
+ final int beganIndex = range.getStart() + (length - text.length());
+
+ length = text.length();
+ text = stripEnd( text, null );
+ final int endedIndex = range.getEnd() - (length - text.length());
+
+ mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
+ }
+
+ /**
+ * Inserts the given block-level markup at the current caret position
+ * within the document. This will prepend two blank lines to ensure that
+ * the block element begins at the start of a new line.
+ *
+ * @param markup The text to insert at the caret.
+ */
+ private void block( final String markup ) {
+ final int pos = mTextArea.getCaretPosition();
+ mTextArea.insertText( pos, format( "%n%n%s", markup ) );
+ }
+
+ /**
+ * Returns the caret position within the current paragraph.
+ *
+ * @return A value from 0 to the length of the current paragraph.
+ */
+ private int getCaretColumn() {
+ return mTextArea.getCaretColumn();
+ }
+
+ @Override
+ public IndexRange getCaretWord() {
+ final var paragraph = getCaretParagraph();
+ final var length = paragraph.length();
+ final var column = getCaretColumn();
+
+ var began = column;
+ var ended = column;
+
+ while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
+ began--;
+ }
+
+ while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
+ ended++;
+ }
+
+ final var iterator = BreakIterator.getWordInstance();
+ iterator.setText( paragraph );
+
+ while( began < length && iterator.isBoundary( began + 1 ) ) {
+ began++;
+ }
+
+ while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
+ ended--;
+ }
+
+ final var offset = getCaretDocumentOffset( column );
+
+ return IndexRange.normalize( began + offset, ended + offset );
+ }
+
+ private int getCaretDocumentOffset( final int column ) {
+ return mTextArea.getCaretPosition() - column;
+ }
+
+ /**
+ * Returns the index of the paragraph where the caret resides.
+ *
+ * @return A number greater than or equal to 0.
+ */
+ private int getCurrentParagraph() {
+ return mTextArea.getCurrentParagraph();
+ }
+
+ /**
+ * Returns the text for the paragraph that contains the caret.
+ *
+ * @return A non-null string, possibly empty.
+ */
+ private String getCaretParagraph() {
+ return getText( getCurrentParagraph() );
+ }
+
+ @Override
+ public String getText( final int paragraph ) {
+ return mTextArea.getText( paragraph );
+ }
+
+ @Override
+ public String getText( final IndexRange indexes )
+ throws IndexOutOfBoundsException {
+ return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
+ }
+
+ @Override
+ public void replaceText( final IndexRange indexes, final String s ) {
+ mTextArea.replaceText( indexes, s );
+ }
+
+ private UndoManager<?> getUndoManager() {
+ return mTextArea.getUndoManager();
+ }
+
+ /**
+ * Returns the path to a {@link Locale}-specific stylesheet.
+ *
+ * @return A non-null string to inject into the HTML document head.
+ */
+ private static String getStylesheetPath( final Locale locale ) {
+ return get(
+ sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
+ locale.getLanguage(),
+ locale.getScript(),
+ locale.getCountry()
+ );
+ }
+
+ private Locale getLocale() {
+ return localeProperty().toLocale();
+ }
+
+ private LocaleProperty localeProperty() {
+ return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
+ }
+
+ /**
+ * Sets the font family name and font size at the same time. When the
+ * workspace is loaded, the default font values are changed, which results
+ * in this method being called.
+ *
+ * @param area Change the font settings for this text area.
+ * @param name New font family name to apply.
+ * @param points New font size to apply (in points, not pixels).
+ */
+ private void setFont(
+ final StyleClassedTextArea area, final String name, final double points ) {
+ runLater( () -> area.setStyle(
+ format(
+ "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points )
+ )
+ ) );
+ }
+
+ private String getFontName() {
+ return fontNameProperty().get();
+ }
+
+ private StringProperty fontNameProperty() {
+ return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
+ }
+
+ private double getFontSize() {
+ return fontSizeProperty().get();
+ }
+
+ private DoubleProperty fontSizeProperty() {
+ return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE );
+ }
+
+ /**
+ * Answers whether the given resource is of compatible {@link MediaType}s.
+ *
+ * @param mediaType The {@link MediaType} to compare.
+ * @return {@code true} if the given {@link MediaType} is suitable for
+ * editing with this type of editor.
+ */
+ @Override
+ public boolean supports( final MediaType mediaType ) {
+ return isMediaType( mediaType ) ||
+ mediaType == TEXT_MARKDOWN ||
+ mediaType == TEXT_R_MARKDOWN ||
+ mediaType == TEXT_R_XML;
}
}