| | import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*; |
| | import static com.keenwrite.util.FileWalker.walk; |
| | -import static java.nio.file.Files.readString; |
| | -import static java.nio.file.Files.writeString; |
| | -import static java.util.concurrent.Executors.newFixedThreadPool; |
| | -import static javafx.application.Platform.runLater; |
| | -import static javafx.event.Event.fireEvent; |
| | -import static javafx.scene.control.Alert.AlertType.INFORMATION; |
| | -import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; |
| | -import static org.apache.commons.io.FilenameUtils.getExtension; |
| | - |
| | -/** |
| | - * Responsible for abstracting how functionality is mapped to the application. |
| | - * This allows users to customize accelerator keys and will provide pluggable |
| | - * functionality so that different text markup languages can change documents |
| | - * using their respective syntax. |
| | - */ |
| | -public final class GuiCommands { |
| | - private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); |
| | - |
| | - private static final String STYLE_SEARCH = "search"; |
| | - |
| | - /** |
| | - * Sci-fi genres, which are can be longer than other genres, typically fall |
| | - * below 150,000 words at 6 chars per word. This reduces re-allocations of |
| | - * memory when concatenating files together when exporting novels. |
| | - */ |
| | - private static final int DOCUMENT_LENGTH = 150_000 * 6; |
| | - |
| | - /** |
| | - * When an action is executed, this is one of the recipients. |
| | - */ |
| | - private final MainPane mMainPane; |
| | - |
| | - private final MainScene mMainScene; |
| | - |
| | - private final LogView mLogView; |
| | - |
| | - /** |
| | - * Tracks finding text in the active document. |
| | - */ |
| | - private final SearchModel mSearchModel; |
| | - |
| | - public GuiCommands( final MainScene scene, final MainPane pane ) { |
| | - mMainScene = scene; |
| | - mMainPane = pane; |
| | - mLogView = new LogView(); |
| | - mSearchModel = new SearchModel(); |
| | - mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { |
| | - final var editor = getActiveTextEditor(); |
| | - |
| | - // Clear highlighted areas before highlighting a new region. |
| | - if( o != null ) { |
| | - editor.unstylize( STYLE_SEARCH ); |
| | - } |
| | - |
| | - if( n != null ) { |
| | - editor.moveTo( n.getStart() ); |
| | - editor.stylize( n, STYLE_SEARCH ); |
| | - } |
| | - } ); |
| | - |
| | - // When the active text editor changes ... |
| | - mMainPane.textEditorProperty().addListener( |
| | - ( c, o, n ) -> { |
| | - // ... update the haystack. |
| | - mSearchModel.search( getActiveTextEditor().getText() ); |
| | - |
| | - // ... update the status bar with the current caret position. |
| | - if( n != null ) { |
| | - final var w = getWorkspace(); |
| | - final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT ); |
| | - |
| | - // ... preserve the most recent document. |
| | - recentDoc.setValue( n.getFile() ); |
| | - CaretMovedEvent.fire( n.getCaret() ); |
| | - } |
| | - } |
| | - ); |
| | - } |
| | - |
| | - public void file_new() { |
| | - getMainPane().newTextEditor(); |
| | - } |
| | - |
| | - public void file_open() { |
| | - pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); |
| | - } |
| | - |
| | - public void file_close() { |
| | - getMainPane().close(); |
| | - } |
| | - |
| | - public void file_close_all() { |
| | - getMainPane().closeAll(); |
| | - } |
| | - |
| | - public void file_save() { |
| | - getMainPane().save(); |
| | - } |
| | - |
| | - public void file_save_as() { |
| | - pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); |
| | - } |
| | - |
| | - public void file_save_all() { |
| | - getMainPane().saveAll(); |
| | - } |
| | - |
| | - /** |
| | - * Converts the actively edited file in the given file format. |
| | - * |
| | - * @param format The destination file format. |
| | - */ |
| | - private void file_export( final ExportFormat format ) { |
| | - file_export( format, false ); |
| | - } |
| | - |
| | - /** |
| | - * Converts one or more files into the given file format. If {@code dir} |
| | - * is set to true, this will first append all files in the same directory |
| | - * as the actively edited file. |
| | - * |
| | - * @param format The destination file format. |
| | - * @param dir Export all files in the actively edited file's directory. |
| | - */ |
| | - private void file_export( final ExportFormat format, final boolean dir ) { |
| | - final var main = getMainPane(); |
| | - final var editor = main.getTextEditor(); |
| | - final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); |
| | - final var filename = format.toExportFilename( editor.getPath() ); |
| | - final var selected = PDF_DEFAULT.getName().equals( exported.get().getName() ); |
| | - final var selection = pickFile( |
| | - selected ? filename : exported.get(), |
| | - exported.get().toPath().getParent(), |
| | - FILE_EXPORT |
| | - ); |
| | - |
| | - selection.ifPresent( files -> { |
| | - editor.save(); |
| | - |
| | - final var file = files.get( 0 ); |
| | - final var path = file.toPath(); |
| | - final var document = dir ? append( editor ) : editor.getText(); |
| | - final var context = main.createProcessorContext( path, format ); |
| | - |
| | - final var task = new Task<Path>() { |
| | - @Override |
| | - protected Path call() throws Exception { |
| | - final var chain = createProcessors( context ); |
| | - final var export = chain.apply( document ); |
| | - |
| | - // Processors can export binary files. In such cases, processors |
| | - // return null to prevent further processing. |
| | - return export == null ? null : writeString( path, export ); |
| | - } |
| | - }; |
| | - |
| | - task.setOnSucceeded( |
| | - e -> { |
| | - // Remember the exported file name for next time. |
| | - exported.setValue( file ); |
| | - |
| | - final var result = task.getValue(); |
| | - |
| | - // Binary formats must notify users of success independently. |
| | - if( result != null ) { |
| | - clue( "Main.status.export.success", result ); |
| | - } |
| | - } |
| | - ); |
| | - |
| | - task.setOnFailed( e -> { |
| | - final var ex = task.getException(); |
| | - clue( ex ); |
| | - |
| | - if( ex instanceof TypeNotPresentException ) { |
| | - fireExportFailedEvent(); |
| | - } |
| | - } ); |
| | - |
| | - sExecutor.execute( task ); |
| | - } ); |
| | - } |
| | - |
| | - /** |
| | - * @param dir {@code true} means to export all files in the active file |
| | - * editor's directory; {@code false} means to export only the |
| | - * actively edited file. |
| | - */ |
| | - private void file_export_pdf( final boolean dir ) { |
| | - final var workspace = getWorkspace(); |
| | - final var themes = workspace.getFile( |
| | - KEY_TYPESET_CONTEXT_THEMES_PATH |
| | - ); |
| | - final var theme = workspace.stringProperty( |
| | - KEY_TYPESET_CONTEXT_THEME_SELECTION |
| | - ); |
| | - final var chapters = workspace.stringProperty( |
| | - KEY_TYPESET_CONTEXT_CHAPTERS |
| | - ); |
| | - final var settings = ExportSettings |
| | - .builder() |
| | - .with( ExportSettings.Mutator::setTheme, theme ) |
| | - .with( ExportSettings.Mutator::setChapters, chapters ) |
| | - .build(); |
| | - |
| | - if( Typesetter.canRun() ) { |
| | - // If the typesetter is installed, allow the user to select a theme. If |
| | - // the themes aren't installed, a status message will appear. |
| | - if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) { |
| | - file_export( APPLICATION_PDF, dir ); |
| | - } |
| | - } |
| | - else { |
| | - fireExportFailedEvent(); |
| | - } |
| | - } |
| | - |
| | - public void file_export_pdf() { |
| | - file_export_pdf( false ); |
| | - } |
| | - |
| | - public void file_export_pdf_dir() { |
| | - file_export_pdf( true ); |
| | - } |
| | - |
| | - public void file_export_html_svg() { |
| | - file_export( HTML_TEX_SVG ); |
| | - } |
| | - |
| | - public void file_export_html_tex() { |
| | - file_export( HTML_TEX_DELIMITED ); |
| | - } |
| | - |
| | - public void file_export_xhtml_tex() { |
| | - file_export( XHTML_TEX ); |
| | - } |
| | - |
| | - private void fireExportFailedEvent() { |
| | - runLater( ExportFailedEvent::fire ); |
| | - } |
| | - |
| | - public void file_exit() { |
| | - final var window = getWindow(); |
| | - fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); |
| | - } |
| | - |
| | - public void edit_undo() { |
| | - getActiveTextEditor().undo(); |
| | - } |
| | - |
| | - public void edit_redo() { |
| | - getActiveTextEditor().redo(); |
| | - } |
| | - |
| | - public void edit_cut() { |
| | - getActiveTextEditor().cut(); |
| | - } |
| | - |
| | - public void edit_copy() { |
| | - getActiveTextEditor().copy(); |
| | - } |
| | - |
| | - public void edit_paste() { |
| | - getActiveTextEditor().paste(); |
| | - } |
| | - |
| | - public void edit_select_all() { |
| | - getActiveTextEditor().selectAll(); |
| | - } |
| | - |
| | - public void edit_find() { |
| | - final var nodes = getMainScene().getStatusBar().getLeftItems(); |
| | - |
| | - if( nodes.isEmpty() ) { |
| | - final var searchBar = new SearchBar(); |
| | - |
| | - searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); |
| | - searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); |
| | - |
| | - searchBar.setOnCancelAction( event -> { |
| | - final var editor = getActiveTextEditor(); |
| | - nodes.remove( searchBar ); |
| | - editor.unstylize( STYLE_SEARCH ); |
| | - editor.getNode().requestFocus(); |
| | - } ); |
| | - |
| | - searchBar.addInputListener( ( c, o, n ) -> { |
| | - if( n != null && !n.isEmpty() ) { |
| | - mSearchModel.search( n, getActiveTextEditor().getText() ); |
| | - } |
| | - } ); |
| | - |
| | - searchBar.setOnNextAction( event -> edit_find_next() ); |
| | - searchBar.setOnPrevAction( event -> edit_find_prev() ); |
| | - |
| | - nodes.add( searchBar ); |
| | - searchBar.requestFocus(); |
| | - } |
| | - else { |
| | - nodes.clear(); |
| | - } |
| | - } |
| | - |
| | - public void edit_find_next() { |
| | - mSearchModel.advance(); |
| | - } |
| | - |
| | - public void edit_find_prev() { |
| | - mSearchModel.retreat(); |
| | - } |
| | - |
| | - public void edit_preferences() { |
| | - try { |
| | - new PreferencesController( getWorkspace() ).show(); |
| | - } catch( final Exception ex ) { |
| | - clue( ex ); |
| | - } |
| | - } |
| | - |
| | - public void format_bold() { |
| | - getActiveTextEditor().bold(); |
| | - } |
| | - |
| | - public void format_italic() { |
| | - getActiveTextEditor().italic(); |
| | - } |
| | - |
| | - public void format_monospace() { |
| | - getActiveTextEditor().monospace(); |
| | - } |
| | - |
| | - public void format_superscript() { |
| | - getActiveTextEditor().superscript(); |
| | - } |
| | - |
| | - public void format_subscript() { |
| | - getActiveTextEditor().subscript(); |
| | - } |
| | - |
| | - public void format_strikethrough() { |
| | - getActiveTextEditor().strikethrough(); |
| | - } |
| | - |
| | - public void insert_blockquote() { |
| | - getActiveTextEditor().blockquote(); |
| | - } |
| | - |
| | - public void insert_code() { |
| | - getActiveTextEditor().code(); |
| | - } |
| | - |
| | - public void insert_fenced_code_block() { |
| | - getActiveTextEditor().fencedCodeBlock(); |
| | - } |
| | - |
| | - public void insert_link() { |
| | - insertObject( createLinkDialog() ); |
| | - } |
| | - |
| | - public void insert_image() { |
| | - insertObject( createImageDialog() ); |
| | - } |
| | - |
| | - private void insertObject( final Dialog<String> dialog ) { |
| | - final var textArea = getActiveTextEditor().getTextArea(); |
| | - dialog.showAndWait().ifPresent( textArea::replaceSelection ); |
| | - } |
| | - |
| | - private Dialog<String> createLinkDialog() { |
| | - return new LinkDialog( getWindow(), createHyperlinkModel() ); |
| | - } |
| | - |
| | - private Dialog<String> createImageDialog() { |
| | - final var path = getActiveTextEditor().getPath(); |
| | - final var parentDir = path.getParent(); |
| | - return new ImageDialog( getWindow(), parentDir ); |
| | - } |
| | - |
| | - /** |
| | - * Returns one of: selected text, word under cursor, or parsed hyperlink from |
| | - * the Markdown AST. |
| | - * |
| | - * @return An instance containing the link URL and display text. |
| | - */ |
| | - private HyperlinkModel createHyperlinkModel() { |
| | - final var context = getMainPane().createProcessorContext(); |
| | - final var editor = getActiveTextEditor(); |
| | - final var textArea = editor.getTextArea(); |
| | - final var selectedText = textArea.getSelectedText(); |
| | - |
| | - // Convert current paragraph to Markdown nodes. |
| | - final var mp = MarkdownProcessor.create( context ); |
| | - 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 ) { |
| | - textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); |
| | - } |
| | - |
| | - return createHyperlinkModel( link, selectedText ); |
| | - } |
| | - |
| | - private HyperlinkModel createHyperlinkModel( |
| | - final Link link, final String selection ) { |
| | - |
| | - return link == null |
| | - ? new HyperlinkModel( selection, "https://localhost" ) |
| | - : new HyperlinkModel( link ); |
| | - } |
| | - |
| | - public void insert_heading_1() { |
| | - insert_heading( 1 ); |
| | - } |
| | - |
| | - public void insert_heading_2() { |
| | - insert_heading( 2 ); |
| | - } |
| | - |
| | - public void insert_heading_3() { |
| | - insert_heading( 3 ); |
| | - } |
| | - |
| | - private void insert_heading( final int level ) { |
| | - getActiveTextEditor().heading( level ); |
| | - } |
| | - |
| | - public void insert_unordered_list() { |
| | - getActiveTextEditor().unorderedList(); |
| | - } |
| | - |
| | - public void insert_ordered_list() { |
| | - getActiveTextEditor().orderedList(); |
| | - } |
| | - |
| | - public void insert_horizontal_rule() { |
| | - getActiveTextEditor().horizontalRule(); |
| | - } |
| | - |
| | - public void definition_create() { |
| | - getActiveTextDefinition().createDefinition(); |
| | - } |
| | - |
| | - public void definition_rename() { |
| | - getActiveTextDefinition().renameDefinition(); |
| | - } |
| | - |
| | - public void definition_delete() { |
| | - getActiveTextDefinition().deleteDefinitions(); |
| | - } |
| | - |
| | - public void definition_autoinsert() { |
| | - getMainPane().autoinsert(); |
| | - } |
| | - |
| | - public void view_refresh() { |
| | - getMainPane().viewRefresh(); |
| | - } |
| | - |
| | - public void view_preview() { |
| | - getMainPane().viewPreview(); |
| | - } |
| | - |
| | - public void view_outline() { |
| | - getMainPane().viewOutline(); |
| | - } |
| | - |
| | - public void view_files() {getMainPane().viewFiles();} |
| | - |
| | - public void view_statistics() { |
| | - getMainPane().viewStatistics(); |
| | - } |
| | - |
| | - public void view_menubar() { |
| | - getMainScene().toggleMenuBar(); |
| | - } |
| | - |
| | - public void view_toolbar() { |
| | - getMainScene().toggleToolBar(); |
| | - } |
| | - |
| | - public void view_statusbar() { |
| | - getMainScene().toggleStatusBar(); |
| | - } |
| | - |
| | - public void view_log() { |
| | - mLogView.view(); |
| | - } |
| | - |
| | - public void help_about() { |
| | - final var alert = new Alert( INFORMATION ); |
| | - final var prefix = "Dialog.about."; |
| | - alert.setTitle( get( prefix + "title", APP_TITLE ) ); |
| | - alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); |
| | - alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); |
| | - alert.setGraphic( ICON_DIALOG_NODE ); |
| | - alert.initOwner( getWindow() ); |
| | - alert.showAndWait(); |
| | - } |
| | - |
| | - /** |
| | - * Concatenates all the files in the same directory as the given file into |
| | - * a string. The extension is determined by the given file name pattern; the |
| | - * order files are concatenated is based on their numeric sort order (this |
| | - * avoids lexicographic sorting). |
| | - * <p> |
| | - * If the parent path to the file being edited in the text editor cannot |
| | - * be found then this will return the editor's text, without iterating through |
| | - * the parent directory. (Should never happen, but who knows?) |
| | - * </p> |
| | - * <p> |
| | - * New lines are automatically appended to separate each file. |
| | - * </p> |
| | - * |
| | - * @param editor The text editor containing |
| | - * @return All files in the same directory as the file being edited |
| | - * concatenated into a single string. |
| | - */ |
| | - private String append( final TextEditor editor ) { |
| | - final var pattern = editor.getPath(); |
| | - final var parent = pattern.getParent(); |
| | - |
| | - // Short-circuit because nothing else can be done. |
| | - if( parent == null ) { |
| | - clue( "Main.status.export.concat.parent", pattern ); |
| | - return editor.getText(); |
| | - } |
| | - |
| | - final var filename = pattern.getFileName().toString(); |
| | - final var extension = getExtension( filename ); |
| | - |
| | - if( extension.isBlank() ) { |
| | - clue( "Main.status.export.concat.extension", filename ); |
| | - return editor.getText(); |
| | - } |
| | - |
| | - try { |
| | - final var glob = "**/*." + extension; |
| | - final var files = new ArrayList<Path>(); |
| | - final var text = new StringBuilder( DOCUMENT_LENGTH ); |
| | - final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS ); |
| | - final var validator = new RangeValidator( range ); |
| | - final var chapter = new AtomicInteger(); |
| | - |
| | - walk( parent, glob, files::add ); |
| | - files.sort( new AlphanumComparator<>() ); |
| | - files.forEach( file -> { |
| | - try { |
| | - clue( "Main.status.export.concat", file ); |
| | - |
| | - if( validator.test( chapter.incrementAndGet() ) ) { |
| | - text.append( readString( file ) ); |
| | +import static java.lang.System.lineSeparator; |
| | +import static java.nio.file.Files.readString; |
| | +import static java.nio.file.Files.writeString; |
| | +import static java.util.concurrent.Executors.newFixedThreadPool; |
| | +import static javafx.application.Platform.runLater; |
| | +import static javafx.event.Event.fireEvent; |
| | +import static javafx.scene.control.Alert.AlertType.INFORMATION; |
| | +import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; |
| | +import static org.apache.commons.io.FilenameUtils.getExtension; |
| | + |
| | +/** |
| | + * Responsible for abstracting how functionality is mapped to the application. |
| | + * This allows users to customize accelerator keys and will provide pluggable |
| | + * functionality so that different text markup languages can change documents |
| | + * using their respective syntax. |
| | + */ |
| | +public final class GuiCommands { |
| | + private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); |
| | + |
| | + private static final String STYLE_SEARCH = "search"; |
| | + |
| | + /** |
| | + * Sci-fi genres, which are can be longer than other genres, typically fall |
| | + * below 150,000 words at 6 chars per word. This reduces re-allocations of |
| | + * memory when concatenating files together when exporting novels. |
| | + */ |
| | + private static final int DOCUMENT_LENGTH = 150_000 * 6; |
| | + |
| | + /** |
| | + * When an action is executed, this is one of the recipients. |
| | + */ |
| | + private final MainPane mMainPane; |
| | + |
| | + private final MainScene mMainScene; |
| | + |
| | + private final LogView mLogView; |
| | + |
| | + /** |
| | + * Tracks finding text in the active document. |
| | + */ |
| | + private final SearchModel mSearchModel; |
| | + |
| | + public GuiCommands( final MainScene scene, final MainPane pane ) { |
| | + mMainScene = scene; |
| | + mMainPane = pane; |
| | + mLogView = new LogView(); |
| | + mSearchModel = new SearchModel(); |
| | + mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { |
| | + final var editor = getActiveTextEditor(); |
| | + |
| | + // Clear highlighted areas before highlighting a new region. |
| | + if( o != null ) { |
| | + editor.unstylize( STYLE_SEARCH ); |
| | + } |
| | + |
| | + if( n != null ) { |
| | + editor.moveTo( n.getStart() ); |
| | + editor.stylize( n, STYLE_SEARCH ); |
| | + } |
| | + } ); |
| | + |
| | + // When the active text editor changes ... |
| | + mMainPane.textEditorProperty().addListener( |
| | + ( c, o, n ) -> { |
| | + // ... update the haystack. |
| | + mSearchModel.search( getActiveTextEditor().getText() ); |
| | + |
| | + // ... update the status bar with the current caret position. |
| | + if( n != null ) { |
| | + final var w = getWorkspace(); |
| | + final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT ); |
| | + |
| | + // ... preserve the most recent document. |
| | + recentDoc.setValue( n.getFile() ); |
| | + CaretMovedEvent.fire( n.getCaret() ); |
| | + } |
| | + } |
| | + ); |
| | + } |
| | + |
| | + public void file_new() { |
| | + getMainPane().newTextEditor(); |
| | + } |
| | + |
| | + public void file_open() { |
| | + pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); |
| | + } |
| | + |
| | + public void file_close() { |
| | + getMainPane().close(); |
| | + } |
| | + |
| | + public void file_close_all() { |
| | + getMainPane().closeAll(); |
| | + } |
| | + |
| | + public void file_save() { |
| | + getMainPane().save(); |
| | + } |
| | + |
| | + public void file_save_as() { |
| | + pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); |
| | + } |
| | + |
| | + public void file_save_all() { |
| | + getMainPane().saveAll(); |
| | + } |
| | + |
| | + /** |
| | + * Converts the actively edited file in the given file format. |
| | + * |
| | + * @param format The destination file format. |
| | + */ |
| | + private void file_export( final ExportFormat format ) { |
| | + file_export( format, false ); |
| | + } |
| | + |
| | + /** |
| | + * Converts one or more files into the given file format. If {@code dir} |
| | + * is set to true, this will first append all files in the same directory |
| | + * as the actively edited file. |
| | + * |
| | + * @param format The destination file format. |
| | + * @param dir Export all files in the actively edited file's directory. |
| | + */ |
| | + private void file_export( final ExportFormat format, final boolean dir ) { |
| | + final var main = getMainPane(); |
| | + final var editor = main.getTextEditor(); |
| | + final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); |
| | + final var filename = format.toExportFilename( editor.getPath() ); |
| | + final var selected = PDF_DEFAULT.getName() |
| | + .equals( exported.get().getName() ); |
| | + final var selection = pickFile( |
| | + selected ? filename : exported.get(), |
| | + exported.get().toPath().getParent(), |
| | + FILE_EXPORT |
| | + ); |
| | + |
| | + selection.ifPresent( files -> { |
| | + editor.save(); |
| | + |
| | + final var file = files.get( 0 ); |
| | + final var path = file.toPath(); |
| | + final var document = dir ? append( editor ) : editor.getText(); |
| | + final var context = main.createProcessorContext( path, format ); |
| | + |
| | + final var task = new Task<Path>() { |
| | + @Override |
| | + protected Path call() throws Exception { |
| | + final var chain = createProcessors( context ); |
| | + final var export = chain.apply( document ); |
| | + |
| | + // Processors can export binary files. In such cases, processors |
| | + // return null to prevent further processing. |
| | + return export == null ? null : writeString( path, export ); |
| | + } |
| | + }; |
| | + |
| | + task.setOnSucceeded( |
| | + e -> { |
| | + // Remember the exported file name for next time. |
| | + exported.setValue( file ); |
| | + |
| | + final var result = task.getValue(); |
| | + |
| | + // Binary formats must notify users of success independently. |
| | + if( result != null ) { |
| | + clue( "Main.status.export.success", result ); |
| | + } |
| | + } |
| | + ); |
| | + |
| | + task.setOnFailed( e -> { |
| | + final var ex = task.getException(); |
| | + clue( ex ); |
| | + |
| | + if( ex instanceof TypeNotPresentException ) { |
| | + fireExportFailedEvent(); |
| | + } |
| | + } ); |
| | + |
| | + sExecutor.execute( task ); |
| | + } ); |
| | + } |
| | + |
| | + /** |
| | + * @param dir {@code true} means to export all files in the active file |
| | + * editor's directory; {@code false} means to export only the |
| | + * actively edited file. |
| | + */ |
| | + private void file_export_pdf( final boolean dir ) { |
| | + final var workspace = getWorkspace(); |
| | + final var themes = workspace.getFile( |
| | + KEY_TYPESET_CONTEXT_THEMES_PATH |
| | + ); |
| | + final var theme = workspace.stringProperty( |
| | + KEY_TYPESET_CONTEXT_THEME_SELECTION |
| | + ); |
| | + final var chapters = workspace.stringProperty( |
| | + KEY_TYPESET_CONTEXT_CHAPTERS |
| | + ); |
| | + final var settings = ExportSettings |
| | + .builder() |
| | + .with( ExportSettings.Mutator::setTheme, theme ) |
| | + .with( ExportSettings.Mutator::setChapters, chapters ) |
| | + .build(); |
| | + |
| | + if( Typesetter.canRun() ) { |
| | + // If the typesetter is installed, allow the user to select a theme. If |
| | + // the themes aren't installed, a status message will appear. |
| | + if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) { |
| | + file_export( APPLICATION_PDF, dir ); |
| | + } |
| | + } |
| | + else { |
| | + fireExportFailedEvent(); |
| | + } |
| | + } |
| | + |
| | + public void file_export_pdf() { |
| | + file_export_pdf( false ); |
| | + } |
| | + |
| | + public void file_export_pdf_dir() { |
| | + file_export_pdf( true ); |
| | + } |
| | + |
| | + public void file_export_html_svg() { |
| | + file_export( HTML_TEX_SVG ); |
| | + } |
| | + |
| | + public void file_export_html_tex() { |
| | + file_export( HTML_TEX_DELIMITED ); |
| | + } |
| | + |
| | + public void file_export_xhtml_tex() { |
| | + file_export( XHTML_TEX ); |
| | + } |
| | + |
| | + private void fireExportFailedEvent() { |
| | + runLater( ExportFailedEvent::fire ); |
| | + } |
| | + |
| | + public void file_exit() { |
| | + final var window = getWindow(); |
| | + fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); |
| | + } |
| | + |
| | + public void edit_undo() { |
| | + getActiveTextEditor().undo(); |
| | + } |
| | + |
| | + public void edit_redo() { |
| | + getActiveTextEditor().redo(); |
| | + } |
| | + |
| | + public void edit_cut() { |
| | + getActiveTextEditor().cut(); |
| | + } |
| | + |
| | + public void edit_copy() { |
| | + getActiveTextEditor().copy(); |
| | + } |
| | + |
| | + public void edit_paste() { |
| | + getActiveTextEditor().paste(); |
| | + } |
| | + |
| | + public void edit_select_all() { |
| | + getActiveTextEditor().selectAll(); |
| | + } |
| | + |
| | + public void edit_find() { |
| | + final var nodes = getMainScene().getStatusBar().getLeftItems(); |
| | + |
| | + if( nodes.isEmpty() ) { |
| | + final var searchBar = new SearchBar(); |
| | + |
| | + searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); |
| | + searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); |
| | + |
| | + searchBar.setOnCancelAction( event -> { |
| | + final var editor = getActiveTextEditor(); |
| | + nodes.remove( searchBar ); |
| | + editor.unstylize( STYLE_SEARCH ); |
| | + editor.getNode().requestFocus(); |
| | + } ); |
| | + |
| | + searchBar.addInputListener( ( c, o, n ) -> { |
| | + if( n != null && !n.isEmpty() ) { |
| | + mSearchModel.search( n, getActiveTextEditor().getText() ); |
| | + } |
| | + } ); |
| | + |
| | + searchBar.setOnNextAction( event -> edit_find_next() ); |
| | + searchBar.setOnPrevAction( event -> edit_find_prev() ); |
| | + |
| | + nodes.add( searchBar ); |
| | + searchBar.requestFocus(); |
| | + } |
| | + else { |
| | + nodes.clear(); |
| | + } |
| | + } |
| | + |
| | + public void edit_find_next() { |
| | + mSearchModel.advance(); |
| | + } |
| | + |
| | + public void edit_find_prev() { |
| | + mSearchModel.retreat(); |
| | + } |
| | + |
| | + public void edit_preferences() { |
| | + try { |
| | + new PreferencesController( getWorkspace() ).show(); |
| | + } catch( final Exception ex ) { |
| | + clue( ex ); |
| | + } |
| | + } |
| | + |
| | + public void format_bold() { |
| | + getActiveTextEditor().bold(); |
| | + } |
| | + |
| | + public void format_italic() { |
| | + getActiveTextEditor().italic(); |
| | + } |
| | + |
| | + public void format_monospace() { |
| | + getActiveTextEditor().monospace(); |
| | + } |
| | + |
| | + public void format_superscript() { |
| | + getActiveTextEditor().superscript(); |
| | + } |
| | + |
| | + public void format_subscript() { |
| | + getActiveTextEditor().subscript(); |
| | + } |
| | + |
| | + public void format_strikethrough() { |
| | + getActiveTextEditor().strikethrough(); |
| | + } |
| | + |
| | + public void insert_blockquote() { |
| | + getActiveTextEditor().blockquote(); |
| | + } |
| | + |
| | + public void insert_code() { |
| | + getActiveTextEditor().code(); |
| | + } |
| | + |
| | + public void insert_fenced_code_block() { |
| | + getActiveTextEditor().fencedCodeBlock(); |
| | + } |
| | + |
| | + public void insert_link() { |
| | + insertObject( createLinkDialog() ); |
| | + } |
| | + |
| | + public void insert_image() { |
| | + insertObject( createImageDialog() ); |
| | + } |
| | + |
| | + private void insertObject( final Dialog<String> dialog ) { |
| | + final var textArea = getActiveTextEditor().getTextArea(); |
| | + dialog.showAndWait().ifPresent( textArea::replaceSelection ); |
| | + } |
| | + |
| | + private Dialog<String> createLinkDialog() { |
| | + return new LinkDialog( getWindow(), createHyperlinkModel() ); |
| | + } |
| | + |
| | + private Dialog<String> createImageDialog() { |
| | + final var path = getActiveTextEditor().getPath(); |
| | + final var parentDir = path.getParent(); |
| | + return new ImageDialog( getWindow(), parentDir ); |
| | + } |
| | + |
| | + /** |
| | + * Returns one of: selected text, word under cursor, or parsed hyperlink from |
| | + * the Markdown AST. |
| | + * |
| | + * @return An instance containing the link URL and display text. |
| | + */ |
| | + private HyperlinkModel createHyperlinkModel() { |
| | + final var context = getMainPane().createProcessorContext(); |
| | + final var editor = getActiveTextEditor(); |
| | + final var textArea = editor.getTextArea(); |
| | + final var selectedText = textArea.getSelectedText(); |
| | + |
| | + // Convert current paragraph to Markdown nodes. |
| | + final var mp = MarkdownProcessor.create( context ); |
| | + 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 ) { |
| | + textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); |
| | + } |
| | + |
| | + return createHyperlinkModel( link, selectedText ); |
| | + } |
| | + |
| | + private HyperlinkModel createHyperlinkModel( |
| | + final Link link, final String selection ) { |
| | + |
| | + return link == null |
| | + ? new HyperlinkModel( selection, "https://localhost" ) |
| | + : new HyperlinkModel( link ); |
| | + } |
| | + |
| | + public void insert_heading_1() { |
| | + insert_heading( 1 ); |
| | + } |
| | + |
| | + public void insert_heading_2() { |
| | + insert_heading( 2 ); |
| | + } |
| | + |
| | + public void insert_heading_3() { |
| | + insert_heading( 3 ); |
| | + } |
| | + |
| | + private void insert_heading( final int level ) { |
| | + getActiveTextEditor().heading( level ); |
| | + } |
| | + |
| | + public void insert_unordered_list() { |
| | + getActiveTextEditor().unorderedList(); |
| | + } |
| | + |
| | + public void insert_ordered_list() { |
| | + getActiveTextEditor().orderedList(); |
| | + } |
| | + |
| | + public void insert_horizontal_rule() { |
| | + getActiveTextEditor().horizontalRule(); |
| | + } |
| | + |
| | + public void definition_create() { |
| | + getActiveTextDefinition().createDefinition(); |
| | + } |
| | + |
| | + public void definition_rename() { |
| | + getActiveTextDefinition().renameDefinition(); |
| | + } |
| | + |
| | + public void definition_delete() { |
| | + getActiveTextDefinition().deleteDefinitions(); |
| | + } |
| | + |
| | + public void definition_autoinsert() { |
| | + getMainPane().autoinsert(); |
| | + } |
| | + |
| | + public void view_refresh() { |
| | + getMainPane().viewRefresh(); |
| | + } |
| | + |
| | + public void view_preview() { |
| | + getMainPane().viewPreview(); |
| | + } |
| | + |
| | + public void view_outline() { |
| | + getMainPane().viewOutline(); |
| | + } |
| | + |
| | + public void view_files() {getMainPane().viewFiles();} |
| | + |
| | + public void view_statistics() { |
| | + getMainPane().viewStatistics(); |
| | + } |
| | + |
| | + public void view_menubar() { |
| | + getMainScene().toggleMenuBar(); |
| | + } |
| | + |
| | + public void view_toolbar() { |
| | + getMainScene().toggleToolBar(); |
| | + } |
| | + |
| | + public void view_statusbar() { |
| | + getMainScene().toggleStatusBar(); |
| | + } |
| | + |
| | + public void view_log() { |
| | + mLogView.view(); |
| | + } |
| | + |
| | + public void help_about() { |
| | + final var alert = new Alert( INFORMATION ); |
| | + final var prefix = "Dialog.about."; |
| | + alert.setTitle( get( prefix + "title", APP_TITLE ) ); |
| | + alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); |
| | + alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); |
| | + alert.setGraphic( ICON_DIALOG_NODE ); |
| | + alert.initOwner( getWindow() ); |
| | + alert.showAndWait(); |
| | + } |
| | + |
| | + /** |
| | + * Concatenates all the files in the same directory as the given file into |
| | + * a string. The extension is determined by the given file name pattern; the |
| | + * order files are concatenated is based on their numeric sort order (this |
| | + * avoids lexicographic sorting). |
| | + * <p> |
| | + * If the parent path to the file being edited in the text editor cannot |
| | + * be found then this will return the editor's text, without iterating through |
| | + * the parent directory. (Should never happen, but who knows?) |
| | + * </p> |
| | + * <p> |
| | + * New lines are automatically appended to separate each file. |
| | + * </p> |
| | + * |
| | + * @param editor The text editor containing |
| | + * @return All files in the same directory as the file being edited |
| | + * concatenated into a single string. |
| | + */ |
| | + private String append( final TextEditor editor ) { |
| | + final var pattern = editor.getPath(); |
| | + final var parent = pattern.getParent(); |
| | + |
| | + // Short-circuit because nothing else can be done. |
| | + if( parent == null ) { |
| | + clue( "Main.status.export.concat.parent", pattern ); |
| | + return editor.getText(); |
| | + } |
| | + |
| | + final var filename = pattern.getFileName().toString(); |
| | + final var extension = getExtension( filename ); |
| | + |
| | + if( extension.isBlank() ) { |
| | + clue( "Main.status.export.concat.extension", filename ); |
| | + return editor.getText(); |
| | + } |
| | + |
| | + try { |
| | + final var glob = "**/*." + extension; |
| | + final var files = new ArrayList<Path>(); |
| | + final var text = new StringBuilder( DOCUMENT_LENGTH ); |
| | + final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS ); |
| | + final var validator = new RangeValidator( range ); |
| | + final var chapter = new AtomicInteger(); |
| | + |
| | + walk( parent, glob, files::add ); |
| | + files.sort( new AlphanumComparator<>() ); |
| | + files.forEach( file -> { |
| | + try { |
| | + clue( "Main.status.export.concat", file ); |
| | + |
| | + if( validator.test( chapter.incrementAndGet() ) ) { |
| | + // Ensure multiple files are separated by an EOL. |
| | + text.append( readString( file ) ).append( lineSeparator() ); |
| | } |
| | } catch( final IOException ex ) { |