Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M src/main/java/com/keenwrite/dom/DocumentConverter.java
33
44
import org.jetbrains.annotations.NotNull;
5
import org.jsoup.Jsoup;
56
import org.jsoup.helper.W3CDom;
7
import org.jsoup.nodes.Document.OutputSettings.Syntax;
68
import org.jsoup.nodes.Node;
79
import org.jsoup.nodes.TextNode;
...
1416
import static com.keenwrite.dom.DocumentParser.sDomImplementation;
1517
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
18
import static java.util.Map.*;
1619
1720
/**
...
2831
   * ligature.
2932
   */
30
  private static final Map<String, String> LIGATURES = new LinkedHashMap<>();
31
32
  static {
33
    LIGATURES.put( "ffi", "\uFB03" );
34
    LIGATURES.put( "ffl", "\uFB04" );
35
    LIGATURES.put( "ff", "\uFB00" );
36
    LIGATURES.put( "fi", "\uFB01" );
37
    LIGATURES.put( "fl", "\uFB02" );
38
  }
33
  private static final Map<String, String> LIGATURES = ofEntries(
34
    entry( "ffi", "ffi" ),
35
    entry( "ffl", "ffl" ),
36
    entry( "ff", "ff" ),
37
    entry( "fi", "fi" ),
38
    entry( "fl", "fl" )
39
  );
3940
4041
  private static final NodeVisitor LIGATURE_VISITOR = new NodeVisitor() {
...
5859
5960
    @Override
60
    public void tail( final @NotNull Node node, final int depth ) {
61
    }
61
    public void tail( final @NotNull Node node, final int depth ) { }
6262
  };
6363
6464
  @Override
6565
  public @NotNull Document fromJsoup( final org.jsoup.nodes.Document in ) {
6666
    assert in != null;
6767
6868
    final var out = DocumentParser.newDocument();
69
    final org.jsoup.nodes.DocumentType doctype = in.documentType();
69
    final var doctype = in.documentType();
7070
7171
    if( doctype != null ) {
...
8484
8585
    return out;
86
  }
87
88
  /**
89
   * Converts the given non-well-formed HTML document into an XML document
90
   * while preserving whitespace.
91
   *
92
   * @param html The document to convert.
93
   * @return The converted document as an object model.
94
   */
95
  public static org.jsoup.nodes.Document parse( final String html ) {
96
    final var document = Jsoup.parse( html );
97
98
    document
99
      .outputSettings()
100
      .syntax( Syntax.xml )
101
      .prettyPrint( false );
102
103
    return document;
86104
  }
87105
}
M src/main/java/com/keenwrite/preview/HtmlPreview.java
1111
import javafx.embed.swing.SwingNode;
1212
import org.greenrobot.eventbus.Subscribe;
13
import org.jsoup.Jsoup;
1413
1514
import javax.swing.*;
...
158157
   */
159158
  public void render( final String html ) {
160
    final var doc = CONVERTER.fromJsoup( Jsoup.parse( decorate( html ) ) );
159
    final var jsoupDoc = DocumentConverter.parse( decorate( html ) );
160
    final var doc = CONVERTER.fromJsoup( jsoupDoc );
161161
    final var uri = getBaseUri();
162
    doc.setDocumentURI( uri );
163162
163
    doc.setDocumentURI( uri );
164164
    invokeLater( () -> mPreview.render( doc, uri ) );
165
166165
    DocumentChangedEvent.fire( html );
167166
  }
M src/main/java/com/keenwrite/processors/markdown/BaseMarkdownProcessor.java
22
package com.keenwrite.processors.markdown;
33
4
import com.keenwrite.dom.DocumentConverter;
45
import com.keenwrite.processors.ExecutorProcessor;
56
import com.keenwrite.processors.Processor;
...
1819
import com.vladsch.flexmark.util.data.MutableDataSet;
1920
import com.vladsch.flexmark.util.misc.Extension;
20
import org.jsoup.Jsoup;
21
import org.jsoup.nodes.Document.OutputSettings.Syntax;
2221
2322
import java.util.ArrayList;
...
114113
   */
115114
  private String toXhtml( final String html ) {
116
    final var document = Jsoup.parse( html );
117
    document.outputSettings().syntax( Syntax.xml );
118
    return document.html();
115
    return DocumentConverter.parse( html ).html();
119116
  }
120117
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
7373
          addAction( "file.export.pdf", e -> actions.file_export_pdf() ),
7474
          addAction( "file.export.pdf.dir", e -> actions.file_export_pdf_dir() ),
75
          addAction( "file.export.pdf.repeat", e -> actions.file_export_repeat() ),
7576
          addAction( "file.export.html_svg", e -> actions.file_export_html_svg() ),
7677
          addAction( "file.export.html_tex", e -> actions.file_export_html_tex() ),
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
2828
import com.keenwrite.util.RangeValidator;
2929
import com.vladsch.flexmark.ast.Link;
30
import javafx.concurrent.Task;
31
import javafx.scene.control.Alert;
32
import javafx.scene.control.Dialog;
33
import javafx.stage.Window;
34
import javafx.stage.WindowEvent;
35
36
import java.io.File;
37
import java.io.IOException;
38
import java.nio.file.Path;
39
import java.util.ArrayList;
40
import java.util.List;
41
import java.util.Optional;
42
import java.util.concurrent.ExecutorService;
43
import java.util.concurrent.atomic.AtomicInteger;
44
45
import static com.keenwrite.Bootstrap.*;
46
import static com.keenwrite.ExportFormat.*;
47
import static com.keenwrite.Messages.get;
48
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
49
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
50
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
51
import static com.keenwrite.events.StatusEvent.clue;
52
import static com.keenwrite.preferences.AppKeys.*;
53
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
54
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
55
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
56
import static com.keenwrite.util.FileWalker.walk;
57
import static java.lang.System.lineSeparator;
58
import static java.nio.file.Files.readString;
59
import static java.nio.file.Files.writeString;
60
import static java.util.concurrent.Executors.newFixedThreadPool;
61
import static javafx.application.Platform.runLater;
62
import static javafx.event.Event.fireEvent;
63
import static javafx.scene.control.Alert.AlertType.INFORMATION;
64
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
65
import static org.apache.commons.io.FilenameUtils.getExtension;
66
67
/**
68
 * Responsible for abstracting how functionality is mapped to the application.
69
 * This allows users to customize accelerator keys and will provide pluggable
70
 * functionality so that different text markup languages can change documents
71
 * using their respective syntax.
72
 */
73
public final class GuiCommands {
74
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
75
76
  private static final String STYLE_SEARCH = "search";
77
78
  /**
79
   * Sci-fi genres, which are can be longer than other genres, typically fall
80
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
81
   * memory when concatenating files together when exporting novels.
82
   */
83
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
84
85
  /**
86
   * When an action is executed, this is one of the recipients.
87
   */
88
  private final MainPane mMainPane;
89
90
  private final MainScene mMainScene;
91
92
  private final LogView mLogView;
93
94
  /**
95
   * Tracks finding text in the active document.
96
   */
97
  private final SearchModel mSearchModel;
98
99
  private boolean mCanTypeset;
100
101
  public GuiCommands( final MainScene scene, final MainPane pane ) {
102
    mMainScene = scene;
103
    mMainPane = pane;
104
    mLogView = new LogView();
105
    mSearchModel = new SearchModel();
106
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
107
      final var editor = getActiveTextEditor();
108
109
      // Clear highlighted areas before highlighting a new region.
110
      if( o != null ) {
111
        editor.unstylize( STYLE_SEARCH );
112
      }
113
114
      if( n != null ) {
115
        editor.moveTo( n.getStart() );
116
        editor.stylize( n, STYLE_SEARCH );
117
      }
118
    } );
119
120
    // When the active text editor changes ...
121
    mMainPane.textEditorProperty().addListener(
122
      ( c, o, n ) -> {
123
        // ... update the haystack.
124
        mSearchModel.search( getActiveTextEditor().getText() );
125
126
        // ... update the status bar with the current caret position.
127
        if( n != null ) {
128
          final var w = getWorkspace();
129
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
130
131
          // ... preserve the most recent document.
132
          recentDoc.setValue( n.getFile() );
133
          CaretMovedEvent.fire( n.getCaret() );
134
        }
135
      }
136
    );
137
  }
138
139
  public void file_new() {
140
    getMainPane().newTextEditor();
141
  }
142
143
  public void file_open() {
144
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
145
  }
146
147
  public void file_close() {
148
    getMainPane().close();
149
  }
150
151
  public void file_close_all() {
152
    getMainPane().closeAll();
153
  }
154
155
  public void file_save() {
156
    getMainPane().save();
157
  }
158
159
  public void file_save_as() {
160
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
161
  }
162
163
  public void file_save_all() {
164
    getMainPane().saveAll();
165
  }
166
167
  /**
168
   * Converts the actively edited file in the given file format.
169
   *
170
   * @param format The destination file format.
171
   */
172
  private void file_export( final ExportFormat format ) {
173
    file_export( format, false );
174
  }
175
176
  /**
177
   * Converts one or more files into the given file format. If {@code dir}
178
   * is set to true, this will first append all files in the same directory
179
   * as the actively edited file.
180
   *
181
   * @param format The destination file format.
182
   * @param dir    Export all files in the actively edited file's directory.
183
   */
184
  private void file_export( final ExportFormat format, final boolean dir ) {
185
    final var main = getMainPane();
186
    final var editor = main.getTextEditor();
187
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
188
    final var filename = format.toExportFilename( editor.getPath() );
189
    final var exportParent = exported.get().toPath().getParent();
190
    final var editorParent = editor.getPath().getParent();
191
    final var userHomeParent = USER_DIRECTORY.toPath();
192
    final var exportPath = exportParent != null
193
      ? exportParent
194
      : editorParent != null
195
      ? editorParent
196
      : userHomeParent;
197
198
    final var selected = PDF_DEFAULT
199
      .getName()
200
      .equals( exported.get().getName() );
201
    final var selection = pickFile(
202
      selected
203
        ? filename
204
        : exported.get(),
205
      exportPath,
206
      FILE_EXPORT
207
    );
208
209
    selection.ifPresent( files -> {
210
      editor.save();
211
212
      final var sourceFile = files.get( 0 );
213
      final var sourcePath = sourceFile.toPath();
214
      final var document = dir ? append( editor ) : editor.getText();
215
      final var context = main.createProcessorContext( sourcePath, format );
216
217
      final var task = new Task<Path>() {
218
        @Override
219
        protected Path call() throws Exception {
220
          final var chain = createProcessors( context );
221
          final var export = chain.apply( document );
222
223
          // Processors can export binary files. In such cases, processors
224
          // return null to prevent further processing.
225
          return export == null ? null : writeString( sourcePath, export );
226
        }
227
      };
228
229
      task.setOnSucceeded(
230
        e -> {
231
          // Remember the exported file name for next time.
232
          exported.setValue( sourceFile );
233
234
          final var result = task.getValue();
235
236
          // Binary formats must notify users of success independently.
237
          if( result != null ) {
238
            clue( "Main.status.export.success", result );
239
          }
240
        }
241
      );
242
243
      task.setOnFailed( e -> {
244
        final var ex = task.getException();
245
        clue( ex );
246
247
        if( ex instanceof TypeNotPresentException ) {
248
          fireExportFailedEvent();
249
        }
250
      } );
251
252
      sExecutor.execute( task );
253
    } );
254
  }
255
256
  /**
257
   * @param dir {@code true} means to export all files in the active file
258
   *            editor's directory; {@code false} means to export only the
259
   *            actively edited file.
260
   */
261
  private void file_export_pdf( final boolean dir ) {
262
    final var workspace = getWorkspace();
263
    final var themes = workspace.getFile(
264
      KEY_TYPESET_CONTEXT_THEMES_PATH
265
    );
266
    final var theme = workspace.stringProperty(
267
      KEY_TYPESET_CONTEXT_THEME_SELECTION
268
    );
269
    final var chapters = workspace.stringProperty(
270
      KEY_TYPESET_CONTEXT_CHAPTERS
271
    );
272
    final var settings = ExportSettings
273
      .builder()
274
      .with( ExportSettings.Mutator::setTheme, theme )
275
      .with( ExportSettings.Mutator::setChapters, chapters )
276
      .build();
277
278
    // Don't re-validate the typesetter installation each time. If the
279
    // user mucks up the typesetter installation, it'll get caught the
280
    // next time the application is started. Don't use |= because it
281
    // won't short-circuit.
282
    mCanTypeset = mCanTypeset || Typesetter.canRun();
283
284
    if( mCanTypeset ) {
285
      // If the typesetter is installed, allow the user to select a theme. If
286
      // the themes aren't installed, a status message will appear.
287
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
288
        file_export( APPLICATION_PDF, dir );
289
      }
290
    }
291
    else {
292
      fireExportFailedEvent();
293
    }
294
  }
295
296
  public void file_export_pdf() {
297
    file_export_pdf( false );
298
  }
299
300
  public void file_export_pdf_dir() {
301
    file_export_pdf( true );
302
  }
303
304
  public void file_export_html_svg() {
305
    file_export( HTML_TEX_SVG );
306
  }
307
308
  public void file_export_html_tex() {
309
    file_export( HTML_TEX_DELIMITED );
310
  }
311
312
  public void file_export_xhtml_tex() {
313
    file_export( XHTML_TEX );
314
  }
315
316
  private void fireExportFailedEvent() {
317
    runLater( ExportFailedEvent::fire );
318
  }
319
320
  public void file_exit() {
321
    final var window = getWindow();
322
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
323
  }
324
325
  public void edit_undo() {
326
    getActiveTextEditor().undo();
327
  }
328
329
  public void edit_redo() {
330
    getActiveTextEditor().redo();
331
  }
332
333
  public void edit_cut() {
334
    getActiveTextEditor().cut();
335
  }
336
337
  public void edit_copy() {
338
    getActiveTextEditor().copy();
339
  }
340
341
  public void edit_paste() {
342
    getActiveTextEditor().paste();
343
  }
344
345
  public void edit_select_all() {
346
    getActiveTextEditor().selectAll();
347
  }
348
349
  public void edit_find() {
350
    final var nodes = getMainScene().getStatusBar().getLeftItems();
351
352
    if( nodes.isEmpty() ) {
353
      final var searchBar = new SearchBar();
354
355
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
356
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
357
358
      searchBar.setOnCancelAction( event -> {
359
        final var editor = getActiveTextEditor();
360
        nodes.remove( searchBar );
361
        editor.unstylize( STYLE_SEARCH );
362
        editor.getNode().requestFocus();
363
      } );
364
365
      searchBar.addInputListener( ( c, o, n ) -> {
366
        if( n != null && !n.isEmpty() ) {
367
          mSearchModel.search( n, getActiveTextEditor().getText() );
368
        }
369
      } );
370
371
      searchBar.setOnNextAction( event -> edit_find_next() );
372
      searchBar.setOnPrevAction( event -> edit_find_prev() );
373
374
      nodes.add( searchBar );
375
      searchBar.requestFocus();
376
    }
377
    else {
378
      nodes.clear();
379
    }
380
  }
381
382
  public void edit_find_next() {
383
    mSearchModel.advance();
384
  }
385
386
  public void edit_find_prev() {
387
    mSearchModel.retreat();
388
  }
389
390
  public void edit_preferences() {
391
    try {
392
      new PreferencesController( getWorkspace() ).show();
393
    } catch( final Exception ex ) {
394
      clue( ex );
395
    }
396
  }
397
398
  public void format_bold() {
399
    getActiveTextEditor().bold();
400
  }
401
402
  public void format_italic() {
403
    getActiveTextEditor().italic();
404
  }
405
406
  public void format_monospace() {
407
    getActiveTextEditor().monospace();
408
  }
409
410
  public void format_superscript() {
411
    getActiveTextEditor().superscript();
412
  }
413
414
  public void format_subscript() {
415
    getActiveTextEditor().subscript();
416
  }
417
418
  public void format_strikethrough() {
419
    getActiveTextEditor().strikethrough();
420
  }
421
422
  public void insert_blockquote() {
423
    getActiveTextEditor().blockquote();
424
  }
425
426
  public void insert_code() {
427
    getActiveTextEditor().code();
428
  }
429
430
  public void insert_fenced_code_block() {
431
    getActiveTextEditor().fencedCodeBlock();
432
  }
433
434
  public void insert_link() {
435
    insertObject( createLinkDialog() );
436
  }
437
438
  public void insert_image() {
439
    insertObject( createImageDialog() );
440
  }
441
442
  private void insertObject( final Dialog<String> dialog ) {
443
    final var textArea = getActiveTextEditor().getTextArea();
444
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
445
  }
446
447
  private Dialog<String> createLinkDialog() {
448
    return new LinkDialog( getWindow(), createHyperlinkModel() );
449
  }
450
451
  private Dialog<String> createImageDialog() {
452
    final var path = getActiveTextEditor().getPath();
453
    final var parentDir = path.getParent();
454
    return new ImageDialog( getWindow(), parentDir );
455
  }
456
457
  /**
458
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
459
   * the Markdown AST.
460
   *
461
   * @return An instance containing the link URL and display text.
462
   */
463
  private HyperlinkModel createHyperlinkModel() {
464
    final var context = getMainPane().createProcessorContext();
465
    final var editor = getActiveTextEditor();
466
    final var textArea = editor.getTextArea();
467
    final var selectedText = textArea.getSelectedText();
468
469
    // Convert current paragraph to Markdown nodes.
470
    final var mp = MarkdownProcessor.create( context );
471
    final var p = textArea.getCurrentParagraph();
472
    final var paragraph = textArea.getText( p );
473
    final var node = mp.toNode( paragraph );
474
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
475
    final var link = visitor.process( node );
476
477
    if( link != null ) {
478
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
479
    }
480
481
    return createHyperlinkModel( link, selectedText );
482
  }
483
484
  private HyperlinkModel createHyperlinkModel(
485
    final Link link, final String selection ) {
486
487
    return link == null
488
      ? new HyperlinkModel( selection, "https://localhost" )
489
      : new HyperlinkModel( link );
490
  }
491
492
  public void insert_heading_1() {
493
    insert_heading( 1 );
494
  }
495
496
  public void insert_heading_2() {
497
    insert_heading( 2 );
498
  }
499
500
  public void insert_heading_3() {
501
    insert_heading( 3 );
502
  }
503
504
  private void insert_heading( final int level ) {
505
    getActiveTextEditor().heading( level );
506
  }
507
508
  public void insert_unordered_list() {
509
    getActiveTextEditor().unorderedList();
510
  }
511
512
  public void insert_ordered_list() {
513
    getActiveTextEditor().orderedList();
514
  }
515
516
  public void insert_horizontal_rule() {
517
    getActiveTextEditor().horizontalRule();
518
  }
519
520
  public void definition_create() {
521
    getActiveTextDefinition().createDefinition();
522
  }
523
524
  public void definition_rename() {
525
    getActiveTextDefinition().renameDefinition();
526
  }
527
528
  public void definition_delete() {
529
    getActiveTextDefinition().deleteDefinitions();
530
  }
531
532
  public void definition_autoinsert() {
533
    getMainPane().autoinsert();
534
  }
535
536
  public void view_refresh() {
537
    getMainPane().viewRefresh();
538
  }
539
540
  public void view_preview() {
541
    getMainPane().viewPreview();
542
  }
543
544
  public void view_outline() {
545
    getMainPane().viewOutline();
546
  }
547
548
  public void view_files() { getMainPane().viewFiles(); }
549
550
  public void view_statistics() {
551
    getMainPane().viewStatistics();
552
  }
553
554
  public void view_menubar() {
555
    getMainScene().toggleMenuBar();
556
  }
557
558
  public void view_toolbar() {
559
    getMainScene().toggleToolBar();
560
  }
561
562
  public void view_statusbar() {
563
    getMainScene().toggleStatusBar();
564
  }
565
566
  public void view_log() {
567
    mLogView.view();
568
  }
569
570
  public void help_about() {
571
    final var alert = new Alert( INFORMATION );
572
    final var prefix = "Dialog.about.";
573
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
574
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
575
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
576
    alert.setGraphic( ICON_DIALOG_NODE );
577
    alert.initOwner( getWindow() );
578
    alert.showAndWait();
30
import javafx.concurrent.Service;
31
import javafx.concurrent.Task;
32
import javafx.scene.control.Alert;
33
import javafx.scene.control.Dialog;
34
import javafx.stage.Window;
35
import javafx.stage.WindowEvent;
36
37
import java.io.File;
38
import java.io.IOException;
39
import java.nio.file.Path;
40
import java.util.ArrayList;
41
import java.util.List;
42
import java.util.Optional;
43
import java.util.concurrent.atomic.AtomicInteger;
44
45
import static com.keenwrite.Bootstrap.*;
46
import static com.keenwrite.ExportFormat.*;
47
import static com.keenwrite.Messages.get;
48
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
49
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
50
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
51
import static com.keenwrite.events.StatusEvent.clue;
52
import static com.keenwrite.preferences.AppKeys.*;
53
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
54
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
55
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
56
import static com.keenwrite.util.FileWalker.walk;
57
import static java.lang.System.lineSeparator;
58
import static java.nio.file.Files.readString;
59
import static java.nio.file.Files.writeString;
60
import static javafx.application.Platform.runLater;
61
import static javafx.event.Event.fireEvent;
62
import static javafx.scene.control.Alert.AlertType.INFORMATION;
63
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
64
import static org.apache.commons.io.FilenameUtils.getExtension;
65
66
/**
67
 * Responsible for abstracting how functionality is mapped to the application.
68
 * This allows users to customize accelerator keys and will provide pluggable
69
 * functionality so that different text markup languages can change documents
70
 * using their respective syntax.
71
 */
72
public final class GuiCommands {
73
  private static final String STYLE_SEARCH = "search";
74
75
  /**
76
   * Sci-fi genres, which are can be longer than other genres, typically fall
77
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
78
   * memory when concatenating files together when exporting novels.
79
   */
80
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
81
82
  /**
83
   * When an action is executed, this is one of the recipients.
84
   */
85
  private final MainPane mMainPane;
86
87
  private final MainScene mMainScene;
88
89
  private final LogView mLogView;
90
91
  /**
92
   * Tracks finding text in the active document.
93
   */
94
  private final SearchModel mSearchModel;
95
96
  private boolean mCanTypeset;
97
98
  /**
99
   * A {@link Task} can only be run once, so wrap it in a {@link Service} to
100
   * allow re-launching the typesetting task repeatedly.
101
   */
102
  private Service<Path> mTypesetService;
103
104
  /**
105
   * Prevent a race-condition between checking to see if the typesetting task
106
   * is running and restarting the task itself.
107
   */
108
  private final Object mMutex = new Object();
109
110
  public GuiCommands( final MainScene scene, final MainPane pane ) {
111
    mMainScene = scene;
112
    mMainPane = pane;
113
    mLogView = new LogView();
114
    mSearchModel = new SearchModel();
115
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
116
      final var editor = getActiveTextEditor();
117
118
      // Clear highlighted areas before highlighting a new region.
119
      if( o != null ) {
120
        editor.unstylize( STYLE_SEARCH );
121
      }
122
123
      if( n != null ) {
124
        editor.moveTo( n.getStart() );
125
        editor.stylize( n, STYLE_SEARCH );
126
      }
127
    } );
128
129
    // When the active text editor changes ...
130
    mMainPane.textEditorProperty().addListener(
131
      ( c, o, n ) -> {
132
        // ... update the haystack.
133
        mSearchModel.search( getActiveTextEditor().getText() );
134
135
        // ... update the status bar with the current caret position.
136
        if( n != null ) {
137
          final var w = getWorkspace();
138
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
139
140
          // ... preserve the most recent document.
141
          recentDoc.setValue( n.getFile() );
142
          CaretMovedEvent.fire( n.getCaret() );
143
        }
144
      }
145
    );
146
  }
147
148
  public void file_new() {
149
    getMainPane().newTextEditor();
150
  }
151
152
  public void file_open() {
153
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
154
  }
155
156
  public void file_close() {
157
    getMainPane().close();
158
  }
159
160
  public void file_close_all() {
161
    getMainPane().closeAll();
162
  }
163
164
  public void file_save() {
165
    getMainPane().save();
166
  }
167
168
  public void file_save_as() {
169
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
170
  }
171
172
  public void file_save_all() {
173
    getMainPane().saveAll();
174
  }
175
176
  /**
177
   * Converts the actively edited file in the given file format.
178
   *
179
   * @param format The destination file format.
180
   */
181
  private void file_export( final ExportFormat format ) {
182
    file_export( format, false );
183
  }
184
185
  /**
186
   * Converts one or more files into the given file format. If {@code dir}
187
   * is set to true, this will first append all files in the same directory
188
   * as the actively edited file.
189
   *
190
   * @param format The destination file format.
191
   * @param dir    Export all files in the actively edited file's directory.
192
   */
193
  private void file_export( final ExportFormat format, final boolean dir ) {
194
    final var editor = getMainPane().getTextEditor();
195
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
196
    final var exportParent = exported.get().toPath().getParent();
197
    final var editorParent = editor.getPath().getParent();
198
    final var userHomeParent = USER_DIRECTORY.toPath();
199
    final var exportPath = exportParent != null
200
      ? exportParent
201
      : editorParent != null
202
      ? editorParent
203
      : userHomeParent;
204
205
    final var filename = format.toExportFilename( editor.getPath() );
206
    final var selected = PDF_DEFAULT
207
      .getName()
208
      .equals( exported.get().getName() );
209
    final var selection = pickFile(
210
      selected
211
        ? filename
212
        : exported.get(),
213
      exportPath,
214
      FILE_EXPORT
215
    );
216
217
    selection.ifPresent( files -> file_export( editor, format, files, dir ) );
218
  }
219
220
  private void file_export(
221
    final TextEditor editor,
222
    final ExportFormat format,
223
    final List<File> files,
224
    final boolean dir ) {
225
    editor.save();
226
    final var main = getMainPane();
227
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
228
229
    final var sourceFile = files.get( 0 );
230
    final var sourcePath = sourceFile.toPath();
231
    final var document = dir ? append( editor ) : editor.getText();
232
    final var context = main.createProcessorContext( sourcePath, format );
233
234
    final var service = new Service<Path>() {
235
      @Override
236
      protected Task<Path> createTask() {
237
        final var task = new Task<Path>() {
238
          @Override
239
          protected Path call() throws Exception {
240
            final var chain = createProcessors( context );
241
            final var export = chain.apply( document );
242
243
            // Processors can export binary files. In such cases, processors
244
            // return null to prevent further processing.
245
            return export == null ? null : writeString( sourcePath, export );
246
          }
247
        };
248
249
        task.setOnSucceeded(
250
          e -> {
251
            // Remember the exported file name for next time.
252
            exported.setValue( sourceFile );
253
254
            final var result = task.getValue();
255
256
            // Binary formats must notify users of success independently.
257
            if( result != null ) {
258
              clue( "Main.status.export.success", result );
259
            }
260
          }
261
        );
262
263
        task.setOnFailed( e -> {
264
          final var ex = task.getException();
265
          clue( ex );
266
267
          if( ex instanceof TypeNotPresentException ) {
268
            fireExportFailedEvent();
269
          }
270
        } );
271
272
        return task;
273
      }
274
    };
275
276
    mTypesetService = service;
277
    typeset( service );
278
  }
279
280
  /**
281
   * @param dir {@code true} means to export all files in the active file
282
   *            editor's directory; {@code false} means to export only the
283
   *            actively edited file.
284
   */
285
  private void file_export_pdf( final boolean dir ) {
286
    final var workspace = getWorkspace();
287
    final var themes = workspace.getFile(
288
      KEY_TYPESET_CONTEXT_THEMES_PATH
289
    );
290
    final var theme = workspace.stringProperty(
291
      KEY_TYPESET_CONTEXT_THEME_SELECTION
292
    );
293
    final var chapters = workspace.stringProperty(
294
      KEY_TYPESET_CONTEXT_CHAPTERS
295
    );
296
    final var settings = ExportSettings
297
      .builder()
298
      .with( ExportSettings.Mutator::setTheme, theme )
299
      .with( ExportSettings.Mutator::setChapters, chapters )
300
      .build();
301
302
    // Don't re-validate the typesetter installation each time. If the
303
    // user mucks up the typesetter installation, it'll get caught the
304
    // next time the application is started. Don't use |= because it
305
    // won't short-circuit.
306
    mCanTypeset = mCanTypeset || Typesetter.canRun();
307
308
    if( mCanTypeset ) {
309
      // If the typesetter is installed, allow the user to select a theme. If
310
      // the themes aren't installed, a status message will appear.
311
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
312
        file_export( APPLICATION_PDF, dir );
313
      }
314
    }
315
    else {
316
      fireExportFailedEvent();
317
    }
318
  }
319
320
  public void file_export_pdf() {
321
    file_export_pdf( false );
322
  }
323
324
  public void file_export_pdf_dir() {
325
    file_export_pdf( true );
326
  }
327
328
  public void file_export_repeat() {
329
    typeset( mTypesetService );
330
  }
331
332
  public void file_export_html_svg() {
333
    file_export( HTML_TEX_SVG );
334
  }
335
336
  public void file_export_html_tex() {
337
    file_export( HTML_TEX_DELIMITED );
338
  }
339
340
  public void file_export_xhtml_tex() {
341
    file_export( XHTML_TEX );
342
  }
343
344
  private void fireExportFailedEvent() {
345
    runLater( ExportFailedEvent::fire );
346
  }
347
348
  public void file_exit() {
349
    final var window = getWindow();
350
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
351
  }
352
353
  public void edit_undo() {
354
    getActiveTextEditor().undo();
355
  }
356
357
  public void edit_redo() {
358
    getActiveTextEditor().redo();
359
  }
360
361
  public void edit_cut() {
362
    getActiveTextEditor().cut();
363
  }
364
365
  public void edit_copy() {
366
    getActiveTextEditor().copy();
367
  }
368
369
  public void edit_paste() {
370
    getActiveTextEditor().paste();
371
  }
372
373
  public void edit_select_all() {
374
    getActiveTextEditor().selectAll();
375
  }
376
377
  public void edit_find() {
378
    final var nodes = getMainScene().getStatusBar().getLeftItems();
379
380
    if( nodes.isEmpty() ) {
381
      final var searchBar = new SearchBar();
382
383
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
384
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
385
386
      searchBar.setOnCancelAction( event -> {
387
        final var editor = getActiveTextEditor();
388
        nodes.remove( searchBar );
389
        editor.unstylize( STYLE_SEARCH );
390
        editor.getNode().requestFocus();
391
      } );
392
393
      searchBar.addInputListener( ( c, o, n ) -> {
394
        if( n != null && !n.isEmpty() ) {
395
          mSearchModel.search( n, getActiveTextEditor().getText() );
396
        }
397
      } );
398
399
      searchBar.setOnNextAction( event -> edit_find_next() );
400
      searchBar.setOnPrevAction( event -> edit_find_prev() );
401
402
      nodes.add( searchBar );
403
      searchBar.requestFocus();
404
    }
405
    else {
406
      nodes.clear();
407
    }
408
  }
409
410
  public void edit_find_next() {
411
    mSearchModel.advance();
412
  }
413
414
  public void edit_find_prev() {
415
    mSearchModel.retreat();
416
  }
417
418
  public void edit_preferences() {
419
    try {
420
      new PreferencesController( getWorkspace() ).show();
421
    } catch( final Exception ex ) {
422
      clue( ex );
423
    }
424
  }
425
426
  public void format_bold() {
427
    getActiveTextEditor().bold();
428
  }
429
430
  public void format_italic() {
431
    getActiveTextEditor().italic();
432
  }
433
434
  public void format_monospace() {
435
    getActiveTextEditor().monospace();
436
  }
437
438
  public void format_superscript() {
439
    getActiveTextEditor().superscript();
440
  }
441
442
  public void format_subscript() {
443
    getActiveTextEditor().subscript();
444
  }
445
446
  public void format_strikethrough() {
447
    getActiveTextEditor().strikethrough();
448
  }
449
450
  public void insert_blockquote() {
451
    getActiveTextEditor().blockquote();
452
  }
453
454
  public void insert_code() {
455
    getActiveTextEditor().code();
456
  }
457
458
  public void insert_fenced_code_block() {
459
    getActiveTextEditor().fencedCodeBlock();
460
  }
461
462
  public void insert_link() {
463
    insertObject( createLinkDialog() );
464
  }
465
466
  public void insert_image() {
467
    insertObject( createImageDialog() );
468
  }
469
470
  private void insertObject( final Dialog<String> dialog ) {
471
    final var textArea = getActiveTextEditor().getTextArea();
472
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
473
  }
474
475
  private Dialog<String> createLinkDialog() {
476
    return new LinkDialog( getWindow(), createHyperlinkModel() );
477
  }
478
479
  private Dialog<String> createImageDialog() {
480
    final var path = getActiveTextEditor().getPath();
481
    final var parentDir = path.getParent();
482
    return new ImageDialog( getWindow(), parentDir );
483
  }
484
485
  /**
486
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
487
   * the Markdown AST.
488
   *
489
   * @return An instance containing the link URL and display text.
490
   */
491
  private HyperlinkModel createHyperlinkModel() {
492
    final var context = getMainPane().createProcessorContext();
493
    final var editor = getActiveTextEditor();
494
    final var textArea = editor.getTextArea();
495
    final var selectedText = textArea.getSelectedText();
496
497
    // Convert current paragraph to Markdown nodes.
498
    final var mp = MarkdownProcessor.create( context );
499
    final var p = textArea.getCurrentParagraph();
500
    final var paragraph = textArea.getText( p );
501
    final var node = mp.toNode( paragraph );
502
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
503
    final var link = visitor.process( node );
504
505
    if( link != null ) {
506
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
507
    }
508
509
    return createHyperlinkModel( link, selectedText );
510
  }
511
512
  private HyperlinkModel createHyperlinkModel(
513
    final Link link, final String selection ) {
514
515
    return link == null
516
      ? new HyperlinkModel( selection, "https://localhost" )
517
      : new HyperlinkModel( link );
518
  }
519
520
  public void insert_heading_1() {
521
    insert_heading( 1 );
522
  }
523
524
  public void insert_heading_2() {
525
    insert_heading( 2 );
526
  }
527
528
  public void insert_heading_3() {
529
    insert_heading( 3 );
530
  }
531
532
  private void insert_heading( final int level ) {
533
    getActiveTextEditor().heading( level );
534
  }
535
536
  public void insert_unordered_list() {
537
    getActiveTextEditor().unorderedList();
538
  }
539
540
  public void insert_ordered_list() {
541
    getActiveTextEditor().orderedList();
542
  }
543
544
  public void insert_horizontal_rule() {
545
    getActiveTextEditor().horizontalRule();
546
  }
547
548
  public void definition_create() {
549
    getActiveTextDefinition().createDefinition();
550
  }
551
552
  public void definition_rename() {
553
    getActiveTextDefinition().renameDefinition();
554
  }
555
556
  public void definition_delete() {
557
    getActiveTextDefinition().deleteDefinitions();
558
  }
559
560
  public void definition_autoinsert() {
561
    getMainPane().autoinsert();
562
  }
563
564
  public void view_refresh() {
565
    getMainPane().viewRefresh();
566
  }
567
568
  public void view_preview() {
569
    getMainPane().viewPreview();
570
  }
571
572
  public void view_outline() {
573
    getMainPane().viewOutline();
574
  }
575
576
  public void view_files() { getMainPane().viewFiles(); }
577
578
  public void view_statistics() {
579
    getMainPane().viewStatistics();
580
  }
581
582
  public void view_menubar() {
583
    getMainScene().toggleMenuBar();
584
  }
585
586
  public void view_toolbar() {
587
    getMainScene().toggleToolBar();
588
  }
589
590
  public void view_statusbar() {
591
    getMainScene().toggleStatusBar();
592
  }
593
594
  public void view_log() {
595
    mLogView.view();
596
  }
597
598
  public void help_about() {
599
    final var alert = new Alert( INFORMATION );
600
    final var prefix = "Dialog.about.";
601
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
602
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
603
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
604
    alert.setGraphic( ICON_DIALOG_NODE );
605
    alert.initOwner( getWindow() );
606
    alert.showAndWait();
607
  }
608
609
  private <T> void typeset( final Service<T> service ) {
610
    synchronized( mMutex ) {
611
      if( service != null && !service.isRunning() ) {
612
        service.reset();
613
        service.start();
614
      }
615
    }
579616
  }
580617
M src/main/resources/com/keenwrite/messages.properties
314314
Wizard.typesetter.container.version=4.3.1
315315
Wizard.typesetter.container.checksum=b741702663234ca36e1555149721580dc31ae76985d50c022a8641c6db2f5b93
316
Wizard.typesetter.themes.version=1.8.1
317
Wizard.typesetter.themes.checksum=0cdb637cd77a1cd672aeedaba7ae364214aff7ffd28dd2812a8f360dfcea9d28
316
Wizard.typesetter.themes.version=1.8.2
317
Wizard.typesetter.themes.checksum=00e0f46ea2cb4a812a4780b61a838ea94d78167c1abc9e16767401914a5e989d
318318
319319
# STEP 1: Introduction panel (all)
...
522522
Action.file.export.pdf.dir.text=_Joined PDF
523523
Action.file.export.pdf.dir.icon=FILE_PDF_ALT
524
525
Action.file.export.pdf.repeat.description=Repeat previous typesetting command
526
Action.file.export.pdf.repeat.accelerator=Shortcut+Shift+E
527
Action.file.export.pdf.repeat.text=_Repeat Export
528
Action.file.export.pdf.repeat.icon=FILE_PDF_ALT
524529
525530
Action.file.export.html_svg.description=Export the current document as HTML + SVG
M src/main/resources/com/keenwrite/preview/webview.css
305305
306306
/* LYRICS ***/
307
div.lyrics {
307
div.lyrics p {
308308
  margin: 0;
309309
  padding: 0;
310310
  white-space: pre-line;
311311
  font-style: italic;
312312
}
313313
314
div.lyrics:first-line {
314
div.lyrics:first-line p {
315315
  line-height: 0;
316316
}
317
318317