Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M images/broken-camera.svg
1
<svg height='79pt' viewBox='0 0 100 79' width='100pt' xmlns='http://www.w3.org/2000/svg'><g fill='#454545'><path d='m32.175781 46.207031c1.316407 6.023438 6.628907 10.4375 12.847657 10.675781zm0 0'/><path d='m27.167969 40.105469-1.195313.949219.96875.804687c.050782-.59375.125-1.175781.226563-1.753906zm0 0'/><path d='m42.394531 3.949219-10.054687.875c-3.105469.269531-5.71875 2.414062-6.546875 5.382812l-1.464844 5.222657-13.660156 1.183593c-4.113281.355469-7.160157 3.949219-6.800781 8.023438l3.910156 44.269531c.363281 4.070312 3.992187 7.085938 8.105468 6.730469l46.832032-4.058594-12.457032-10.347656c-.992187.253906-2.007812.453125-3.0625.542969-10.277343.890624-19.359374-6.65625-20.261718-16.832032-.089844-1.042968-.070313-2.070312.007812-3.082031l-.96875-.804687 1.195313-.949219c1.4375-8.042969 8.160156-14.476563 16.765625-15.222657.835937-.074218 1.660156-.070312 2.476562-.035156l3.726563-2.953125zm0 0'/><path d='m40.9375 46.152344 11.859375 11.742187c.570313.070313 1.144531.121094 1.730469.121094 7.558594 0 13.714844-6.09375 13.714844-13.578125 0-7.480469-6.15625-13.578125-13.714844-13.578125s-13.714844 6.097656-13.714844 13.578125c0 .582031.050781 1.152344.125 1.714844zm0 0'/><path d='m57.953125 3.363281 4.472656 19-4.183593 2.269531c9.007812 1.988282 15.382812 10.316407 14.554687 19.664063-.804687 9.132813-8.207031 16.128906-17.148437 16.824219l10.453124 12.335937 17.75 1.539063c4.113282.355468 7.742188-2.660156 8.101563-6.734375l3.910156-44.265625c.363281-4.074219-2.683593-7.667969-6.796875-8.023438l-13.660156-1.183594-1.480469-5.226562c-.832031-2.96875-3.441406-5.113281-6.546875-5.382812zm0 0'/></g></svg>
1
<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www.w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c.332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531.03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2.511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-.367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1.699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094.0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5.0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-.195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4.191406-3.652344.207031-.015625.414063-.015625.617187-.007812l.933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2.820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3.429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3.429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 .140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281.808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2.472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4.285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1.9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-.667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1.253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 0'/></g></svg>
22
M installer
88
# ---------------------------------------------------------------------------
99
10
source build-template
10
source $HOME/bin/build-template
1111
1212
readonly APP_NAME=$(find "${SCRIPT_DIR}/src" -type f -name "settings.properties" -exec cat {} \; | grep "application.title=" | cut -d'=' -f2)
M src/main/java/com/scrivenvar/MainWindow.java
3838
import com.scrivenvar.preferences.UserPreferences;
3939
import com.scrivenvar.preview.HTMLPreviewPane;
40
import com.scrivenvar.processors.Processor;
41
import com.scrivenvar.processors.ProcessorFactory;
42
import com.scrivenvar.service.Options;
43
import com.scrivenvar.service.Snitch;
44
import com.scrivenvar.service.events.Notifier;
45
import com.scrivenvar.util.Action;
46
import com.scrivenvar.util.ActionBuilder;
47
import com.scrivenvar.util.ActionUtils;
48
import javafx.beans.binding.Bindings;
49
import javafx.beans.binding.BooleanBinding;
50
import javafx.beans.property.BooleanProperty;
51
import javafx.beans.property.SimpleBooleanProperty;
52
import javafx.beans.value.ChangeListener;
53
import javafx.beans.value.ObservableBooleanValue;
54
import javafx.beans.value.ObservableValue;
55
import javafx.collections.ListChangeListener.Change;
56
import javafx.collections.ObservableList;
57
import javafx.event.Event;
58
import javafx.event.EventHandler;
59
import javafx.geometry.Pos;
60
import javafx.scene.Node;
61
import javafx.scene.Scene;
62
import javafx.scene.control.*;
63
import javafx.scene.control.Alert.AlertType;
64
import javafx.scene.image.Image;
65
import javafx.scene.image.ImageView;
66
import javafx.scene.input.KeyEvent;
67
import javafx.scene.layout.BorderPane;
68
import javafx.scene.layout.VBox;
69
import javafx.scene.text.Text;
70
import javafx.stage.Window;
71
import javafx.stage.WindowEvent;
72
import javafx.util.Duration;
73
import org.apache.commons.lang3.SystemUtils;
74
import org.controlsfx.control.StatusBar;
75
import org.fxmisc.richtext.StyleClassedTextArea;
76
import org.reactfx.value.Val;
77
import org.xhtmlrenderer.util.XRLog;
78
79
import java.nio.file.Path;
80
import java.util.HashMap;
81
import java.util.Map;
82
import java.util.Observable;
83
import java.util.Observer;
84
import java.util.function.Function;
85
import java.util.prefs.Preferences;
86
87
import static com.scrivenvar.Constants.*;
88
import static com.scrivenvar.Messages.get;
89
import static com.scrivenvar.util.StageState.*;
90
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
91
import static javafx.application.Platform.runLater;
92
import static javafx.event.Event.fireEvent;
93
import static javafx.scene.input.KeyCode.ENTER;
94
import static javafx.scene.input.KeyCode.TAB;
95
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
96
97
/**
98
 * Main window containing a tab pane in the center for file editors.
99
 */
100
public class MainWindow implements Observer {
101
  /**
102
   * The {@code OPTIONS} variable must be declared before all other variables
103
   * to prevent subsequent initializations from failing due to missing user
104
   * preferences.
105
   */
106
  private final static Options OPTIONS = Services.load( Options.class );
107
  private final static Snitch SNITCH = Services.load( Snitch.class );
108
  private final static Notifier NOTIFIER = Services.load( Notifier.class );
109
110
  private final Scene mScene;
111
  private final StatusBar mStatusBar;
112
  private final Text mLineNumberText;
113
  private final TextField mFindTextField;
114
115
  private final Object mMutex = new Object();
116
117
  /**
118
   * Prevents re-instantiation of processing classes.
119
   */
120
  private final Map<FileEditorTab, Processor<String>> mProcessors =
121
      new HashMap<>();
122
123
  private final Map<String, String> mResolvedMap =
124
      new HashMap<>( DEFAULT_MAP_SIZE );
125
126
  /**
127
   * Called when the definition data is changed.
128
   */
129
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
130
      mTreeHandler = event -> {
131
    exportDefinitions( getDefinitionPath() );
132
    interpolateResolvedMap();
133
    renderActiveTab();
134
  };
135
136
  /**
137
   * Called to switch to the definition pane when the user presses the TAB key.
138
   */
139
  private final EventHandler<? super KeyEvent> mTabKeyHandler =
140
      (EventHandler<KeyEvent>) event -> {
141
        if( event.getCode() == TAB ) {
142
          getDefinitionPane().requestFocus();
143
          event.consume();
144
        }
145
      };
146
147
  /**
148
   * Called to inject the selected item when the user presses ENTER in the
149
   * definition pane.
150
   */
151
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
152
      event -> {
153
        if( event.getCode() == ENTER ) {
154
          getVariableNameInjector().injectSelectedItem();
155
        }
156
      };
157
158
  private final ChangeListener<Integer> mCaretPositionListener =
159
      ( observable, oldPosition, newPosition ) -> {
160
        final FileEditorTab tab = getActiveFileEditorTab();
161
        final EditorPane pane = tab.getEditorPane();
162
        final StyleClassedTextArea editor = pane.getEditor();
163
164
        getLineNumberText().setText(
165
            get( STATUS_BAR_LINE,
166
                 editor.getCurrentParagraph() + 1,
167
                 editor.getParagraphs().size(),
168
                 editor.getCaretPosition()
169
            )
170
        );
171
      };
172
173
  private final ChangeListener<Integer> mCaretParagraphListener =
174
      ( observable, oldIndex, newIndex ) ->
175
          scrollToParagraph( newIndex, true );
176
177
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
178
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
179
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
180
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
181
      mCaretPositionListener,
182
      mCaretParagraphListener );
183
184
  /**
185
   * Listens on the definition pane for double-click events.
186
   */
187
  private final VariableNameInjector mVariableNameInjector
188
      = new VariableNameInjector( mDefinitionPane );
189
190
  public MainWindow() {
191
    mStatusBar = createStatusBar();
192
    mLineNumberText = createLineNumberText();
193
    mFindTextField = createFindTextField();
194
    mScene = createScene();
195
196
    System.getProperties()
197
          .setProperty( "xr.util-logging.loggingEnabled", "true" );
198
    XRLog.setLoggingEnabled( true );
199
200
    initLayout();
201
    initFindInput();
202
    initSnitch();
203
    initDefinitionListener();
204
    initTabAddedListener();
205
    initTabChangedListener();
206
    initPreferences();
207
    initVariableNameInjector();
208
209
    NOTIFIER.addObserver( this );
210
  }
211
212
  private void initLayout() {
213
    final Scene appScene = getScene();
214
215
    appScene.getStylesheets().add( STYLESHEET_SCENE );
216
217
    // TODO: Apply an XML syntax highlighting for XML files.
218
//    appScene.getStylesheets().add( STYLESHEET_XML );
219
    appScene.windowProperty().addListener(
220
        ( observable, oldWindow, newWindow ) ->
221
            newWindow.setOnCloseRequest(
222
                e -> {
223
                  if( !getFileEditorPane().closeAllEditors() ) {
224
                    e.consume();
225
                  }
226
                }
227
            )
228
    );
229
  }
230
231
  /**
232
   * Initialize the find input text field to listen on F3, ENTER, and
233
   * ESCAPE key presses.
234
   */
235
  private void initFindInput() {
236
    final TextField input = getFindTextField();
237
238
    input.setOnKeyPressed( ( KeyEvent event ) -> {
239
      switch( event.getCode() ) {
240
        case F3:
241
        case ENTER:
242
          editFindNext();
243
          break;
244
        case F:
245
          if( !event.isControlDown() ) {
246
            break;
247
          }
248
        case ESCAPE:
249
          getStatusBar().setGraphic( null );
250
          getActiveFileEditorTab().getEditorPane().requestFocus();
251
          break;
252
      }
253
    } );
254
255
    // Remove when the input field loses focus.
256
    input.focusedProperty().addListener(
257
        ( focused, oldFocus, newFocus ) -> {
258
          if( !newFocus ) {
259
            getStatusBar().setGraphic( null );
260
          }
261
        }
262
    );
263
  }
264
265
  /**
266
   * Watch for changes to external files. In particular, this awaits
267
   * modifications to any XSL files associated with XML files being edited.
268
   * When
269
   * an XSL file is modified (external to the application), the snitch's ears
270
   * perk up and the file is reloaded. This keeps the XSL transformation up to
271
   * date with what's on the file system.
272
   */
273
  private void initSnitch() {
274
    SNITCH.addObserver( this );
275
  }
276
277
  /**
278
   * Listen for {@link FileEditorTabPane} to receive open definition file
279
   * event.
280
   */
281
  private void initDefinitionListener() {
282
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
283
        ( final ObservableValue<? extends Path> file,
284
          final Path oldPath, final Path newPath ) -> {
285
          // Indirectly refresh the resolved map.
286
          resetProcessors();
287
288
          openDefinitions( newPath );
289
290
          // Will create new processors and therefore a new resolved map.
291
          renderActiveTab();
292
        }
293
    );
294
  }
295
296
  /**
297
   * When tabs are added, hook the various change listeners onto the new
298
   * tab sothat the preview pane refreshes as necessary.
299
   */
300
  private void initTabAddedListener() {
301
    final FileEditorTabPane editorPane = getFileEditorPane();
302
303
    // Make sure the text processor kicks off when new files are opened.
304
    final ObservableList<Tab> tabs = editorPane.getTabs();
305
306
    // Update the preview pane on tab changes.
307
    tabs.addListener(
308
        ( final Change<? extends Tab> change ) -> {
309
          while( change.next() ) {
310
            if( change.wasAdded() ) {
311
              // Multiple tabs can be added simultaneously.
312
              for( final Tab newTab : change.getAddedSubList() ) {
313
                final FileEditorTab tab = (FileEditorTab) newTab;
314
315
                initTextChangeListener( tab );
316
                initTabKeyEventListener( tab );
317
                initScrollEventListener( tab );
318
//              initSyntaxListener( tab );
319
              }
320
            }
321
          }
322
        }
323
    );
324
  }
325
326
  private void initScrollEventListener( final FileEditorTab tab ) {
327
    final var scrollPane = tab.getScrollPane();
328
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
329
330
    // Before the drag handler can be attached, the scroll bar for the
331
    // text editor pane must be visible.
332
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
333
        runLater( () -> {
334
          if( newShow ) {
335
            final var handler = new ScrollEventHandler( scrollPane, scrollBar );
336
            handler.enabledProperty().bind( tab.selectedProperty() );
337
          }
338
        } );
339
340
    Val.flatMap( scrollPane.sceneProperty(), Scene::windowProperty )
341
       .flatMap( Window::showingProperty )
342
       .addListener( listener );
343
  }
344
345
  /**
346
   * Listen for new tab selection events.
347
   */
348
  private void initTabChangedListener() {
349
    final FileEditorTabPane editorPane = getFileEditorPane();
350
351
    // Update the preview pane changing tabs.
352
    editorPane.addTabSelectionListener(
353
        ( tabPane, oldTab, newTab ) -> {
354
          // If there was no old tab, then this is a first time load, which
355
          // can be ignored.
356
          if( oldTab != null ) {
357
            if( newTab != null ) {
358
              final FileEditorTab tab = (FileEditorTab) newTab;
359
              updateVariableNameInjector( tab );
360
              process( tab );
361
            }
362
          }
363
        }
364
    );
365
  }
366
367
  /**
368
   * Reloads the preferences from the previous session.
369
   */
370
  private void initPreferences() {
371
    initDefinitionPane();
372
    getFileEditorPane().initPreferences();
373
  }
374
375
  private void initVariableNameInjector() {
376
    updateVariableNameInjector( getActiveFileEditorTab() );
377
  }
378
379
  /**
380
   * Ensure that the keyboard events are received when a new tab is added
381
   * to the user interface.
382
   *
383
   * @param tab The tab editor that can trigger keyboard events.
384
   */
385
  private void initTabKeyEventListener( final FileEditorTab tab ) {
386
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
387
  }
388
389
  private void initTextChangeListener( final FileEditorTab tab ) {
390
    tab.addTextChangeListener(
391
        ( editor, oldValue, newValue ) -> {
392
          process( tab );
393
          scrollToParagraph( getCurrentParagraphIndex() );
394
        }
395
    );
396
  }
397
398
  private int getCurrentParagraphIndex() {
399
    return getActiveEditorPane().getCurrentParagraphIndex();
400
  }
401
402
  private void scrollToParagraph( final int id ) {
403
    scrollToParagraph( id, false );
404
  }
405
406
  /**
407
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
408
   *              exist.
409
   * @param force {@code true} means to force scrolling immediately, which
410
   *              should only be attempted when it is known that the document
411
   *              has been fully rendered. Otherwise the internal map of ID
412
   *              attributes will be incomplete and scrolling will flounder.
413
   */
414
  private void scrollToParagraph( final int id, final boolean force ) {
415
    synchronized( mMutex ) {
416
      final var previewPane = getPreviewPane();
417
      final var scrollPane = previewPane.getScrollPane();
418
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
419
420
      if( force ) {
421
        previewPane.scrollTo( approxId );
422
      }
423
      else {
424
        previewPane.tryScrollTo( approxId );
425
      }
426
427
      scrollPane.repaint();
428
    }
429
  }
430
431
  private void updateVariableNameInjector( final FileEditorTab tab ) {
432
    getVariableNameInjector().addListener( tab );
433
  }
434
435
  /**
436
   * Called whenever the preview pane becomes out of sync with the file editor
437
   * tab. This can be called when the text changes, the caret paragraph
438
   * changes,
439
   * or the file tab changes.
440
   *
441
   * @param tab The file editor tab that has been changed in some fashion.
442
   */
443
  private void process( final FileEditorTab tab ) {
444
    if( tab == null ) {
445
      return;
446
    }
447
448
    getPreviewPane().setPath( tab.getPath() );
449
450
    final Processor<String> processor = getProcessors().computeIfAbsent(
451
        tab, p -> createProcessor( tab )
452
    );
453
454
    try {
455
      processor.processChain( tab.getEditorText() );
456
    } catch( final Exception ex ) {
457
      error( ex );
458
    }
459
  }
460
461
  private void renderActiveTab() {
462
    process( getActiveFileEditorTab() );
463
  }
464
465
  /**
466
   * Called when a definition source is opened.
467
   *
468
   * @param path Path to the definition source that was opened.
469
   */
470
  private void openDefinitions( final Path path ) {
471
    try {
472
      final DefinitionSource ds = createDefinitionSource( path );
473
      setDefinitionSource( ds );
474
      getUserPreferences().definitionPathProperty().setValue( path.toFile() );
475
      getUserPreferences().save();
476
477
      final Tooltip tooltipPath = new Tooltip( path.toString() );
478
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
479
480
      final DefinitionPane pane = getDefinitionPane();
481
      pane.update( ds );
482
      pane.addTreeChangeHandler( mTreeHandler );
483
      pane.addKeyEventHandler( mDefinitionKeyHandler );
484
      pane.filenameProperty().setValue( path.getFileName().toString() );
485
      pane.setTooltip( tooltipPath );
486
487
      interpolateResolvedMap();
488
    } catch( final Exception e ) {
489
      error( e );
490
    }
491
  }
492
493
  private void exportDefinitions( final Path path ) {
494
    try {
495
      final DefinitionPane pane = getDefinitionPane();
496
      final TreeItem<String> root = pane.getTreeView().getRoot();
497
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
498
499
      if( problemChild == null ) {
500
        getDefinitionSource().getTreeAdapter().export( root, path );
501
        getNotifier().clear();
502
      }
503
      else {
504
        final String msg = get(
505
            "yaml.error.tree.form", problemChild.getValue() );
506
        getNotifier().notify( msg );
507
      }
508
    } catch( final Exception e ) {
509
      error( e );
510
    }
511
  }
512
513
  private void interpolateResolvedMap() {
514
    final Map<String, String> treeMap = getDefinitionPane().toMap();
515
    final Map<String, String> map = new HashMap<>( treeMap );
516
    MapInterpolator.interpolate( map );
517
518
    getResolvedMap().clear();
519
    getResolvedMap().putAll( map );
520
  }
521
522
  private void initDefinitionPane() {
523
    openDefinitions( getDefinitionPath() );
524
  }
525
526
  /**
527
   * Called when an exception occurs that warrants the user's attention.
528
   *
529
   * @param e The exception with a message that the user should know about.
530
   */
531
  private void error( final Exception e ) {
532
    getNotifier().notify( e );
533
  }
534
535
  //---- File actions -------------------------------------------------------
536
537
  /**
538
   * Called when an {@link Observable} instance has changed. This is called
539
   * by both the {@link Snitch} service and the notify service. The @link
540
   * Snitch} service can be called for different file types, including
541
   * {@link DefinitionSource} instances.
542
   *
543
   * @param observable The observed instance.
544
   * @param value      The noteworthy item.
545
   */
546
  @Override
547
  public void update( final Observable observable, final Object value ) {
548
    if( value != null ) {
549
      if( observable instanceof Snitch && value instanceof Path ) {
550
        updateSelectedTab();
551
      }
552
      else if( observable instanceof Notifier && value instanceof String ) {
553
        updateStatusBar( (String) value );
554
      }
555
    }
556
  }
557
558
  /**
559
   * Updates the status bar to show the given message.
560
   *
561
   * @param s The message to show in the status bar.
562
   */
563
  private void updateStatusBar( final String s ) {
564
    runLater(
565
        () -> {
566
          final int index = s.indexOf( '\n' );
567
          final String message = s.substring(
568
              0, index > 0 ? index : s.length() );
569
570
          getStatusBar().setText( message );
571
        }
572
    );
573
  }
574
575
  /**
576
   * Called when a file has been modified.
577
   */
578
  private void updateSelectedTab() {
579
    runLater(
580
        () -> {
581
          // Brute-force XSLT file reload by re-instantiating all processors.
582
          resetProcessors();
583
          renderActiveTab();
584
        }
585
    );
586
  }
587
588
  /**
589
   * After resetting the processors, they will refresh anew to be up-to-date
590
   * with the files (text and definition) currently loaded into the editor.
591
   */
592
  private void resetProcessors() {
593
    getProcessors().clear();
594
  }
595
596
  //---- File actions -------------------------------------------------------
597
598
  private void fileNew() {
599
    getFileEditorPane().newEditor();
600
  }
601
602
  private void fileOpen() {
603
    getFileEditorPane().openFileDialog();
604
  }
605
606
  private void fileClose() {
607
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
608
  }
609
610
  /**
611
   * TODO: Upon closing, first remove the tab change listeners. (There's no
612
   * need to re-render each tab when all are being closed.)
613
   */
614
  private void fileCloseAll() {
615
    getFileEditorPane().closeAllEditors();
616
  }
617
618
  private void fileSave() {
619
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
620
  }
621
622
  private void fileSaveAs() {
623
    final FileEditorTab editor = getActiveFileEditorTab();
624
    getFileEditorPane().saveEditorAs( editor );
625
    getProcessors().remove( editor );
626
627
    try {
628
      process( editor );
629
    } catch( final Exception ex ) {
630
      getNotifier().notify( ex );
631
    }
632
  }
633
634
  private void fileSaveAll() {
635
    getFileEditorPane().saveAllEditors();
636
  }
637
638
  private void fileExit() {
639
    final Window window = getWindow();
640
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
641
  }
642
643
  //---- Edit actions -------------------------------------------------------
644
645
  /**
646
   * Used to find text in the active file editor window.
647
   */
648
  private void editFind() {
649
    final TextField input = getFindTextField();
650
    getStatusBar().setGraphic( input );
651
    input.requestFocus();
652
  }
653
654
  public void editFindNext() {
655
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
656
  }
657
658
  public void editPreferences() {
659
    getUserPreferences().show();
660
  }
661
662
  //---- Insert actions -----------------------------------------------------
663
664
  /**
665
   * Delegates to the active editor to handle wrapping the current text
666
   * selection with leading and trailing strings.
667
   *
668
   * @param leading  The string to put before the selection.
669
   * @param trailing The string to put after the selection.
670
   */
671
  private void insertMarkdown(
672
      final String leading, final String trailing ) {
673
    getActiveEditorPane().surroundSelection( leading, trailing );
674
  }
675
676
  private void insertMarkdown(
677
      final String leading, final String trailing, final String hint ) {
678
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
679
  }
680
681
  //---- Help actions -------------------------------------------------------
682
683
  private void helpAbout() {
684
    final Alert alert = new Alert( AlertType.INFORMATION );
685
    alert.setTitle( get( "Dialog.about.title" ) );
686
    alert.setHeaderText( get( "Dialog.about.header" ) );
687
    alert.setContentText( get( "Dialog.about.content" ) );
688
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
689
    alert.initOwner( getWindow() );
690
691
    alert.showAndWait();
692
  }
693
694
  //---- Member creators ----------------------------------------------------
695
696
  /**
697
   * Factory to create processors that are suited to different file types.
698
   *
699
   * @param tab The tab that is subjected to processing.
700
   * @return A processor suited to the file type specified by the tab's path.
701
   */
702
  private Processor<String> createProcessor( final FileEditorTab tab ) {
703
    return createProcessorFactory().createProcessor( tab );
704
  }
705
706
  private ProcessorFactory createProcessorFactory() {
707
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
708
  }
709
710
  private HTMLPreviewPane createHTMLPreviewPane() {
711
    return new HTMLPreviewPane();
712
  }
713
714
  private DefinitionSource createDefaultDefinitionSource() {
715
    return new YamlDefinitionSource( getDefinitionPath() );
716
  }
717
718
  private DefinitionSource createDefinitionSource( final Path path ) {
719
    try {
720
      return createDefinitionFactory().createDefinitionSource( path );
721
    } catch( final Exception ex ) {
722
      error( ex );
723
      return createDefaultDefinitionSource();
724
    }
725
  }
726
727
  private TextField createFindTextField() {
728
    return new TextField();
729
  }
730
731
  private DefinitionFactory createDefinitionFactory() {
732
    return new DefinitionFactory();
733
  }
734
735
  private StatusBar createStatusBar() {
736
    return new StatusBar();
737
  }
738
739
  private Scene createScene() {
740
    final SplitPane splitPane = new SplitPane(
741
        getDefinitionPane().getNode(),
742
        getFileEditorPane().getNode(),
743
        getPreviewPane().getNode() );
744
745
    splitPane.setDividerPositions(
746
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
747
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
748
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
749
750
    getDefinitionPane().prefHeightProperty()
751
                       .bind( splitPane.heightProperty() );
752
753
    final BorderPane borderPane = new BorderPane();
754
    borderPane.setPrefSize( 1024, 800 );
755
    borderPane.setTop( createMenuBar() );
756
    borderPane.setBottom( getStatusBar() );
757
    borderPane.setCenter( splitPane );
758
759
    final VBox statusBar = new VBox();
760
    statusBar.setAlignment( Pos.BASELINE_CENTER );
761
    statusBar.getChildren().add( getLineNumberText() );
762
    getStatusBar().getRightItems().add( statusBar );
763
764
    // Force preview pane refresh on Windows.
765
    splitPane.getDividers().get( 1 ).positionProperty().addListener(
766
        ( l, oValue, nValue ) -> runLater(
767
            () -> {
768
              if( SystemUtils.IS_OS_WINDOWS ) {
769
                getPreviewPane().getScrollPane().repaint();
770
              }
771
            }
772
        )
773
    );
774
775
    return new Scene( borderPane );
776
  }
777
778
  private Text createLineNumberText() {
779
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
780
  }
781
782
  private Node createMenuBar() {
783
    final BooleanBinding activeFileEditorIsNull =
784
        getFileEditorPane().activeFileEditorProperty().isNull();
785
786
    // File actions
787
    final Action fileNewAction = new ActionBuilder()
788
        .setText( "Main.menu.file.new" )
789
        .setAccelerator( "Shortcut+N" )
790
        .setIcon( FILE_ALT )
791
        .setAction( e -> fileNew() )
792
        .build();
793
    final Action fileOpenAction = new ActionBuilder()
794
        .setText( "Main.menu.file.open" )
795
        .setAccelerator( "Shortcut+O" )
796
        .setIcon( FOLDER_OPEN_ALT )
797
        .setAction( e -> fileOpen() )
798
        .build();
799
    final Action fileCloseAction = new ActionBuilder()
800
        .setText( "Main.menu.file.close" )
801
        .setAccelerator( "Shortcut+W" )
802
        .setAction( e -> fileClose() )
803
        .setDisable( activeFileEditorIsNull )
804
        .build();
805
    final Action fileCloseAllAction = new ActionBuilder()
806
        .setText( "Main.menu.file.close_all" )
807
        .setAction( e -> fileCloseAll() )
808
        .setDisable( activeFileEditorIsNull )
809
        .build();
810
    final Action fileSaveAction = new ActionBuilder()
811
        .setText( "Main.menu.file.save" )
812
        .setAccelerator( "Shortcut+S" )
813
        .setIcon( FLOPPY_ALT )
814
        .setAction( e -> fileSave() )
815
        .setDisable( createActiveBooleanProperty(
816
            FileEditorTab::modifiedProperty ).not() )
817
        .build();
818
    final Action fileSaveAsAction = new ActionBuilder()
819
        .setText( "Main.menu.file.save_as" )
820
        .setAction( e -> fileSaveAs() )
821
        .setDisable( activeFileEditorIsNull )
822
        .build();
823
    final Action fileSaveAllAction = new ActionBuilder()
824
        .setText( "Main.menu.file.save_all" )
825
        .setAccelerator( "Shortcut+Shift+S" )
826
        .setAction( e -> fileSaveAll() )
827
        .setDisable( Bindings.not(
828
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
829
        .build();
830
    final Action fileExitAction = new ActionBuilder()
831
        .setText( "Main.menu.file.exit" )
832
        .setAction( e -> fileExit() )
833
        .build();
834
835
    // Edit actions
836
    final Action editUndoAction = new ActionBuilder()
837
        .setText( "Main.menu.edit.undo" )
838
        .setAccelerator( "Shortcut+Z" )
839
        .setIcon( UNDO )
840
        .setAction( e -> getActiveEditorPane().undo() )
841
        .setDisable( createActiveBooleanProperty(
842
            FileEditorTab::canUndoProperty ).not() )
843
        .build();
844
    final Action editRedoAction = new ActionBuilder()
845
        .setText( "Main.menu.edit.redo" )
846
        .setAccelerator( "Shortcut+Y" )
847
        .setIcon( REPEAT )
848
        .setAction( e -> getActiveEditorPane().redo() )
849
        .setDisable( createActiveBooleanProperty(
850
            FileEditorTab::canRedoProperty ).not() )
851
        .build();
852
    final Action editFindAction = new ActionBuilder()
853
        .setText( "Main.menu.edit.find" )
854
        .setAccelerator( "Ctrl+F" )
855
        .setIcon( SEARCH )
856
        .setAction( e -> editFind() )
857
        .setDisable( activeFileEditorIsNull )
858
        .build();
859
    final Action editFindNextAction = new ActionBuilder()
860
        .setText( "Main.menu.edit.find.next" )
861
        .setAccelerator( "F3" )
862
        .setIcon( null )
863
        .setAction( e -> editFindNext() )
864
        .setDisable( activeFileEditorIsNull )
865
        .build();
866
    final Action editPreferencesAction = new ActionBuilder()
867
        .setText( "Main.menu.edit.preferences" )
868
        .setAccelerator( "Ctrl+Alt+S" )
869
        .setAction( e -> editPreferences() )
870
        .build();
871
872
    // Insert actions
873
    final Action insertBoldAction = new ActionBuilder()
874
        .setText( "Main.menu.insert.bold" )
875
        .setAccelerator( "Shortcut+B" )
876
        .setIcon( BOLD )
877
        .setAction( e -> insertMarkdown( "**", "**" ) )
878
        .setDisable( activeFileEditorIsNull )
879
        .build();
880
    final Action insertItalicAction = new ActionBuilder()
881
        .setText( "Main.menu.insert.italic" )
882
        .setAccelerator( "Shortcut+I" )
883
        .setIcon( ITALIC )
884
        .setAction( e -> insertMarkdown( "*", "*" ) )
885
        .setDisable( activeFileEditorIsNull )
886
        .build();
887
    final Action insertSuperscriptAction = new ActionBuilder()
888
        .setText( "Main.menu.insert.superscript" )
889
        .setAccelerator( "Shortcut+[" )
890
        .setIcon( SUPERSCRIPT )
891
        .setAction( e -> insertMarkdown( "^", "^" ) )
892
        .setDisable( activeFileEditorIsNull )
893
        .build();
894
    final Action insertSubscriptAction = new ActionBuilder()
895
        .setText( "Main.menu.insert.subscript" )
896
        .setAccelerator( "Shortcut+]" )
897
        .setIcon( SUBSCRIPT )
898
        .setAction( e -> insertMarkdown( "~", "~" ) )
899
        .setDisable( activeFileEditorIsNull )
900
        .build();
901
    final Action insertStrikethroughAction = new ActionBuilder()
902
        .setText( "Main.menu.insert.strikethrough" )
903
        .setAccelerator( "Shortcut+T" )
904
        .setIcon( STRIKETHROUGH )
905
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
906
        .setDisable( activeFileEditorIsNull )
907
        .build();
908
    final Action insertBlockquoteAction = new ActionBuilder()
909
        .setText( "Main.menu.insert.blockquote" )
910
        .setAccelerator( "Ctrl+Q" )
911
        .setIcon( QUOTE_LEFT )
912
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
913
        .setDisable( activeFileEditorIsNull )
914
        .build();
915
    final Action insertCodeAction = new ActionBuilder()
916
        .setText( "Main.menu.insert.code" )
917
        .setAccelerator( "Shortcut+K" )
918
        .setIcon( CODE )
919
        .setAction( e -> insertMarkdown( "`", "`" ) )
920
        .setDisable( activeFileEditorIsNull )
921
        .build();
922
    final Action insertFencedCodeBlockAction = new ActionBuilder()
923
        .setText( "Main.menu.insert.fenced_code_block" )
924
        .setAccelerator( "Shortcut+Shift+K" )
925
        .setIcon( FILE_CODE_ALT )
926
        .setAction( e -> insertMarkdown(
927
            "\n\n```\n",
928
            "\n```\n\n",
929
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
930
        .setDisable( activeFileEditorIsNull )
931
        .build();
932
    final Action insertLinkAction = new ActionBuilder()
933
        .setText( "Main.menu.insert.link" )
934
        .setAccelerator( "Shortcut+L" )
935
        .setIcon( LINK )
936
        .setAction( e -> getActiveEditorPane().insertLink() )
937
        .setDisable( activeFileEditorIsNull )
938
        .build();
939
    final Action insertImageAction = new ActionBuilder()
940
        .setText( "Main.menu.insert.image" )
941
        .setAccelerator( "Shortcut+G" )
942
        .setIcon( PICTURE_ALT )
943
        .setAction( e -> getActiveEditorPane().insertImage() )
944
        .setDisable( activeFileEditorIsNull )
945
        .build();
946
947
    // Number of header actions (H1 ... H3)
948
    final int HEADERS = 3;
949
    final Action[] headers = new Action[ HEADERS ];
950
951
    for( int i = 1; i <= HEADERS; i++ ) {
952
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
953
      final String markup = String.format( "%n%n%s ", hashes );
954
      final String text = "Main.menu.insert.header." + i;
955
      final String accelerator = "Shortcut+" + i;
956
      final String prompt = text + ".prompt";
957
958
      headers[ i - 1 ] = new ActionBuilder()
959
          .setText( text )
960
          .setAccelerator( accelerator )
961
          .setIcon( HEADER )
962
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
963
          .setDisable( activeFileEditorIsNull )
964
          .build();
965
    }
966
967
    final Action insertUnorderedListAction = new ActionBuilder()
968
        .setText( "Main.menu.insert.unordered_list" )
969
        .setAccelerator( "Shortcut+U" )
970
        .setIcon( LIST_UL )
971
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
972
        .setDisable( activeFileEditorIsNull )
973
        .build();
974
    final Action insertOrderedListAction = new ActionBuilder()
975
        .setText( "Main.menu.insert.ordered_list" )
976
        .setAccelerator( "Shortcut+Shift+O" )
977
        .setIcon( LIST_OL )
978
        .setAction( e -> insertMarkdown(
979
            "\n\n1. ", "" ) )
980
        .setDisable( activeFileEditorIsNull )
981
        .build();
982
    final Action insertHorizontalRuleAction = new ActionBuilder()
983
        .setText( "Main.menu.insert.horizontal_rule" )
984
        .setAccelerator( "Shortcut+H" )
985
        .setAction( e -> insertMarkdown(
986
            "\n\n---\n\n", "" ) )
987
        .setDisable( activeFileEditorIsNull )
988
        .build();
989
990
    // Help actions
991
    final Action helpAboutAction = new ActionBuilder()
992
        .setText( "Main.menu.help.about" )
993
        .setAction( e -> helpAbout() )
994
        .build();
995
996
    //---- MenuBar ----
997
    final Menu fileMenu = ActionUtils.createMenu(
998
        get( "Main.menu.file" ),
999
        fileNewAction,
1000
        fileOpenAction,
1001
        null,
1002
        fileCloseAction,
1003
        fileCloseAllAction,
1004
        null,
1005
        fileSaveAction,
1006
        fileSaveAsAction,
1007
        fileSaveAllAction,
1008
        null,
1009
        fileExitAction );
1010
1011
    final Menu editMenu = ActionUtils.createMenu(
1012
        get( "Main.menu.edit" ),
1013
        editUndoAction,
1014
        editRedoAction,
1015
        editFindAction,
1016
        editFindNextAction,
1017
        null,
1018
        editPreferencesAction );
1019
1020
    final Menu insertMenu = ActionUtils.createMenu(
1021
        get( "Main.menu.insert" ),
1022
        insertBoldAction,
1023
        insertItalicAction,
1024
        insertSuperscriptAction,
1025
        insertSubscriptAction,
1026
        insertStrikethroughAction,
1027
        insertBlockquoteAction,
1028
        insertCodeAction,
1029
        insertFencedCodeBlockAction,
1030
        null,
1031
        insertLinkAction,
1032
        insertImageAction,
1033
        null,
1034
        headers[ 0 ],
1035
        headers[ 1 ],
1036
        headers[ 2 ],
1037
        null,
1038
        insertUnorderedListAction,
1039
        insertOrderedListAction,
1040
        insertHorizontalRuleAction );
1041
1042
    final Menu helpMenu = ActionUtils.createMenu(
1043
        get( "Main.menu.help" ),
1044
        helpAboutAction );
1045
1046
    final MenuBar menuBar = new MenuBar(
1047
        fileMenu,
1048
        editMenu,
1049
        insertMenu,
1050
        helpMenu );
1051
1052
    //---- ToolBar ----
1053
    final ToolBar toolBar = ActionUtils.createToolBar(
1054
        fileNewAction,
1055
        fileOpenAction,
1056
        fileSaveAction,
1057
        null,
1058
        editUndoAction,
1059
        editRedoAction,
40
import com.scrivenvar.processors.HtmlPreviewProcessor;
41
import com.scrivenvar.processors.Processor;
42
import com.scrivenvar.processors.ProcessorFactory;
43
import com.scrivenvar.service.Options;
44
import com.scrivenvar.service.Snitch;
45
import com.scrivenvar.service.events.Notifier;
46
import com.scrivenvar.util.Action;
47
import com.scrivenvar.util.ActionBuilder;
48
import com.scrivenvar.util.ActionUtils;
49
import javafx.beans.binding.Bindings;
50
import javafx.beans.binding.BooleanBinding;
51
import javafx.beans.property.BooleanProperty;
52
import javafx.beans.property.SimpleBooleanProperty;
53
import javafx.beans.value.ChangeListener;
54
import javafx.beans.value.ObservableBooleanValue;
55
import javafx.beans.value.ObservableValue;
56
import javafx.collections.ListChangeListener.Change;
57
import javafx.collections.ObservableList;
58
import javafx.event.Event;
59
import javafx.event.EventHandler;
60
import javafx.geometry.Pos;
61
import javafx.scene.Node;
62
import javafx.scene.Scene;
63
import javafx.scene.control.*;
64
import javafx.scene.control.Alert.AlertType;
65
import javafx.scene.image.Image;
66
import javafx.scene.image.ImageView;
67
import javafx.scene.input.Clipboard;
68
import javafx.scene.input.ClipboardContent;
69
import javafx.scene.input.KeyEvent;
70
import javafx.scene.layout.BorderPane;
71
import javafx.scene.layout.VBox;
72
import javafx.scene.text.Text;
73
import javafx.stage.Window;
74
import javafx.stage.WindowEvent;
75
import javafx.util.Duration;
76
import org.apache.commons.lang3.SystemUtils;
77
import org.controlsfx.control.StatusBar;
78
import org.fxmisc.richtext.StyleClassedTextArea;
79
import org.reactfx.value.Val;
80
import org.xhtmlrenderer.util.XRLog;
81
82
import java.nio.file.Path;
83
import java.util.HashMap;
84
import java.util.Map;
85
import java.util.Observable;
86
import java.util.Observer;
87
import java.util.function.Function;
88
import java.util.prefs.Preferences;
89
90
import static com.scrivenvar.Constants.*;
91
import static com.scrivenvar.Messages.get;
92
import static com.scrivenvar.util.StageState.*;
93
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
94
import static javafx.application.Platform.runLater;
95
import static javafx.event.Event.fireEvent;
96
import static javafx.scene.input.KeyCode.ENTER;
97
import static javafx.scene.input.KeyCode.TAB;
98
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
99
100
/**
101
 * Main window containing a tab pane in the center for file editors.
102
 */
103
public class MainWindow implements Observer {
104
  /**
105
   * The {@code OPTIONS} variable must be declared before all other variables
106
   * to prevent subsequent initializations from failing due to missing user
107
   * preferences.
108
   */
109
  private final static Options OPTIONS = Services.load( Options.class );
110
  private final static Snitch SNITCH = Services.load( Snitch.class );
111
  private final static Notifier NOTIFIER = Services.load( Notifier.class );
112
113
  private final Scene mScene;
114
  private final StatusBar mStatusBar;
115
  private final Text mLineNumberText;
116
  private final TextField mFindTextField;
117
118
  private final Object mMutex = new Object();
119
120
  /**
121
   * Prevents re-instantiation of processing classes.
122
   */
123
  private final Map<FileEditorTab, Processor<String>> mProcessors =
124
      new HashMap<>();
125
126
  private final Map<String, String> mResolvedMap =
127
      new HashMap<>( DEFAULT_MAP_SIZE );
128
129
  /**
130
   * Called when the definition data is changed.
131
   */
132
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
133
      mTreeHandler = event -> {
134
    exportDefinitions( getDefinitionPath() );
135
    interpolateResolvedMap();
136
    renderActiveTab();
137
  };
138
139
  /**
140
   * Called to switch to the definition pane when the user presses the TAB key.
141
   */
142
  private final EventHandler<? super KeyEvent> mTabKeyHandler =
143
      (EventHandler<KeyEvent>) event -> {
144
        if( event.getCode() == TAB ) {
145
          getDefinitionPane().requestFocus();
146
          event.consume();
147
        }
148
      };
149
150
  /**
151
   * Called to inject the selected item when the user presses ENTER in the
152
   * definition pane.
153
   */
154
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
155
      event -> {
156
        if( event.getCode() == ENTER ) {
157
          getVariableNameInjector().injectSelectedItem();
158
        }
159
      };
160
161
  private final ChangeListener<Integer> mCaretPositionListener =
162
      ( observable, oldPosition, newPosition ) -> {
163
        final FileEditorTab tab = getActiveFileEditorTab();
164
        final EditorPane pane = tab.getEditorPane();
165
        final StyleClassedTextArea editor = pane.getEditor();
166
167
        getLineNumberText().setText(
168
            get( STATUS_BAR_LINE,
169
                 editor.getCurrentParagraph() + 1,
170
                 editor.getParagraphs().size(),
171
                 editor.getCaretPosition()
172
            )
173
        );
174
      };
175
176
  private final ChangeListener<Integer> mCaretParagraphListener =
177
      ( observable, oldIndex, newIndex ) ->
178
          scrollToParagraph( newIndex, true );
179
180
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
181
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
182
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
183
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
184
      mCaretPositionListener,
185
      mCaretParagraphListener );
186
187
  /**
188
   * Listens on the definition pane for double-click events.
189
   */
190
  private final VariableNameInjector mVariableNameInjector
191
      = new VariableNameInjector( mDefinitionPane );
192
193
  public MainWindow() {
194
    mStatusBar = createStatusBar();
195
    mLineNumberText = createLineNumberText();
196
    mFindTextField = createFindTextField();
197
    mScene = createScene();
198
199
    System.getProperties()
200
          .setProperty( "xr.util-logging.loggingEnabled", "true" );
201
    XRLog.setLoggingEnabled( true );
202
203
    initLayout();
204
    initFindInput();
205
    initSnitch();
206
    initDefinitionListener();
207
    initTabAddedListener();
208
    initTabChangedListener();
209
    initPreferences();
210
    initVariableNameInjector();
211
212
    NOTIFIER.addObserver( this );
213
  }
214
215
  private void initLayout() {
216
    final Scene appScene = getScene();
217
218
    appScene.getStylesheets().add( STYLESHEET_SCENE );
219
220
    // TODO: Apply an XML syntax highlighting for XML files.
221
//    appScene.getStylesheets().add( STYLESHEET_XML );
222
    appScene.windowProperty().addListener(
223
        ( observable, oldWindow, newWindow ) ->
224
            newWindow.setOnCloseRequest(
225
                e -> {
226
                  if( !getFileEditorPane().closeAllEditors() ) {
227
                    e.consume();
228
                  }
229
                }
230
            )
231
    );
232
  }
233
234
  /**
235
   * Initialize the find input text field to listen on F3, ENTER, and
236
   * ESCAPE key presses.
237
   */
238
  private void initFindInput() {
239
    final TextField input = getFindTextField();
240
241
    input.setOnKeyPressed( ( KeyEvent event ) -> {
242
      switch( event.getCode() ) {
243
        case F3:
244
        case ENTER:
245
          editFindNext();
246
          break;
247
        case F:
248
          if( !event.isControlDown() ) {
249
            break;
250
          }
251
        case ESCAPE:
252
          getStatusBar().setGraphic( null );
253
          getActiveFileEditorTab().getEditorPane().requestFocus();
254
          break;
255
      }
256
    } );
257
258
    // Remove when the input field loses focus.
259
    input.focusedProperty().addListener(
260
        ( focused, oldFocus, newFocus ) -> {
261
          if( !newFocus ) {
262
            getStatusBar().setGraphic( null );
263
          }
264
        }
265
    );
266
  }
267
268
  /**
269
   * Watch for changes to external files. In particular, this awaits
270
   * modifications to any XSL files associated with XML files being edited.
271
   * When
272
   * an XSL file is modified (external to the application), the snitch's ears
273
   * perk up and the file is reloaded. This keeps the XSL transformation up to
274
   * date with what's on the file system.
275
   */
276
  private void initSnitch() {
277
    SNITCH.addObserver( this );
278
  }
279
280
  /**
281
   * Listen for {@link FileEditorTabPane} to receive open definition file
282
   * event.
283
   */
284
  private void initDefinitionListener() {
285
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
286
        ( final ObservableValue<? extends Path> file,
287
          final Path oldPath, final Path newPath ) -> {
288
          // Indirectly refresh the resolved map.
289
          resetProcessors();
290
291
          openDefinitions( newPath );
292
293
          // Will create new processors and therefore a new resolved map.
294
          renderActiveTab();
295
        }
296
    );
297
  }
298
299
  /**
300
   * When tabs are added, hook the various change listeners onto the new
301
   * tab sothat the preview pane refreshes as necessary.
302
   */
303
  private void initTabAddedListener() {
304
    final FileEditorTabPane editorPane = getFileEditorPane();
305
306
    // Make sure the text processor kicks off when new files are opened.
307
    final ObservableList<Tab> tabs = editorPane.getTabs();
308
309
    // Update the preview pane on tab changes.
310
    tabs.addListener(
311
        ( final Change<? extends Tab> change ) -> {
312
          while( change.next() ) {
313
            if( change.wasAdded() ) {
314
              // Multiple tabs can be added simultaneously.
315
              for( final Tab newTab : change.getAddedSubList() ) {
316
                final FileEditorTab tab = (FileEditorTab) newTab;
317
318
                initTextChangeListener( tab );
319
                initTabKeyEventListener( tab );
320
                initScrollEventListener( tab );
321
//              initSyntaxListener( tab );
322
              }
323
            }
324
          }
325
        }
326
    );
327
  }
328
329
  private void initScrollEventListener( final FileEditorTab tab ) {
330
    final var scrollPane = tab.getScrollPane();
331
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
332
333
    // Before the drag handler can be attached, the scroll bar for the
334
    // text editor pane must be visible.
335
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
336
        runLater( () -> {
337
          if( newShow ) {
338
            final var handler = new ScrollEventHandler( scrollPane, scrollBar );
339
            handler.enabledProperty().bind( tab.selectedProperty() );
340
          }
341
        } );
342
343
    Val.flatMap( scrollPane.sceneProperty(), Scene::windowProperty )
344
       .flatMap( Window::showingProperty )
345
       .addListener( listener );
346
  }
347
348
  /**
349
   * Listen for new tab selection events.
350
   */
351
  private void initTabChangedListener() {
352
    final FileEditorTabPane editorPane = getFileEditorPane();
353
354
    // Update the preview pane changing tabs.
355
    editorPane.addTabSelectionListener(
356
        ( tabPane, oldTab, newTab ) -> {
357
          if( newTab == null ) {
358
            getPreviewPane().clear();
359
          }
360
361
          // If there was no old tab, then this is a first time load, which
362
          // can be ignored.
363
          if( oldTab != null ) {
364
            if( newTab != null ) {
365
              final FileEditorTab tab = (FileEditorTab) newTab;
366
              updateVariableNameInjector( tab );
367
              process( tab );
368
            }
369
          }
370
        }
371
    );
372
  }
373
374
  /**
375
   * Reloads the preferences from the previous session.
376
   */
377
  private void initPreferences() {
378
    initDefinitionPane();
379
    getFileEditorPane().initPreferences();
380
  }
381
382
  private void initVariableNameInjector() {
383
    updateVariableNameInjector( getActiveFileEditorTab() );
384
  }
385
386
  /**
387
   * Ensure that the keyboard events are received when a new tab is added
388
   * to the user interface.
389
   *
390
   * @param tab The tab editor that can trigger keyboard events.
391
   */
392
  private void initTabKeyEventListener( final FileEditorTab tab ) {
393
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
394
  }
395
396
  private void initTextChangeListener( final FileEditorTab tab ) {
397
    tab.addTextChangeListener(
398
        ( editor, oldValue, newValue ) -> {
399
          process( tab );
400
          scrollToParagraph( getCurrentParagraphIndex() );
401
        }
402
    );
403
  }
404
405
  private int getCurrentParagraphIndex() {
406
    return getActiveEditorPane().getCurrentParagraphIndex();
407
  }
408
409
  private void scrollToParagraph( final int id ) {
410
    scrollToParagraph( id, false );
411
  }
412
413
  /**
414
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
415
   *              exist.
416
   * @param force {@code true} means to force scrolling immediately, which
417
   *              should only be attempted when it is known that the document
418
   *              has been fully rendered. Otherwise the internal map of ID
419
   *              attributes will be incomplete and scrolling will flounder.
420
   */
421
  private void scrollToParagraph( final int id, final boolean force ) {
422
    synchronized( mMutex ) {
423
      final var previewPane = getPreviewPane();
424
      final var scrollPane = previewPane.getScrollPane();
425
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
426
427
      if( force ) {
428
        previewPane.scrollTo( approxId );
429
      }
430
      else {
431
        previewPane.tryScrollTo( approxId );
432
      }
433
434
      scrollPane.repaint();
435
    }
436
  }
437
438
  private void updateVariableNameInjector( final FileEditorTab tab ) {
439
    getVariableNameInjector().addListener( tab );
440
  }
441
442
  /**
443
   * Called whenever the preview pane becomes out of sync with the file editor
444
   * tab. This can be called when the text changes, the caret paragraph
445
   * changes, or the file tab changes.
446
   *
447
   * @param tab The file editor tab that has been changed in some fashion.
448
   */
449
  private void process( final FileEditorTab tab ) {
450
    if( tab == null ) {
451
      return;
452
    }
453
454
    getPreviewPane().setPath( tab.getPath() );
455
456
    final Processor<String> processor = getProcessors().computeIfAbsent(
457
        tab, p -> createProcessors( tab )
458
    );
459
460
    try {
461
      processChain( processor, tab.getEditorText() );
462
    } catch( final Exception ex ) {
463
      error( ex );
464
    }
465
  }
466
467
  /**
468
   * Executes the processing chain, operating on the given string.
469
   *
470
   * @param handler The first processor in the chain to call.
471
   * @param text    The initial value of the text to process.
472
   * @return The final value of the text that was processed by the chain.
473
   */
474
  private String processChain( Processor<String> handler, String text ) {
475
    while( handler != null && text != null ) {
476
      text = handler.process( text );
477
      handler = handler.next();
478
    }
479
480
    return text;
481
  }
482
483
  private void renderActiveTab() {
484
    process( getActiveFileEditorTab() );
485
  }
486
487
  /**
488
   * Called when a definition source is opened.
489
   *
490
   * @param path Path to the definition source that was opened.
491
   */
492
  private void openDefinitions( final Path path ) {
493
    try {
494
      final DefinitionSource ds = createDefinitionSource( path );
495
      setDefinitionSource( ds );
496
      getUserPreferences().definitionPathProperty().setValue( path.toFile() );
497
      getUserPreferences().save();
498
499
      final Tooltip tooltipPath = new Tooltip( path.toString() );
500
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
501
502
      final DefinitionPane pane = getDefinitionPane();
503
      pane.update( ds );
504
      pane.addTreeChangeHandler( mTreeHandler );
505
      pane.addKeyEventHandler( mDefinitionKeyHandler );
506
      pane.filenameProperty().setValue( path.getFileName().toString() );
507
      pane.setTooltip( tooltipPath );
508
509
      interpolateResolvedMap();
510
    } catch( final Exception e ) {
511
      error( e );
512
    }
513
  }
514
515
  private void exportDefinitions( final Path path ) {
516
    try {
517
      final DefinitionPane pane = getDefinitionPane();
518
      final TreeItem<String> root = pane.getTreeView().getRoot();
519
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
520
521
      if( problemChild == null ) {
522
        getDefinitionSource().getTreeAdapter().export( root, path );
523
        getNotifier().clear();
524
      }
525
      else {
526
        final String msg = get(
527
            "yaml.error.tree.form", problemChild.getValue() );
528
        getNotifier().notify( msg );
529
      }
530
    } catch( final Exception e ) {
531
      error( e );
532
    }
533
  }
534
535
  private void interpolateResolvedMap() {
536
    final Map<String, String> treeMap = getDefinitionPane().toMap();
537
    final Map<String, String> map = new HashMap<>( treeMap );
538
    MapInterpolator.interpolate( map );
539
540
    getResolvedMap().clear();
541
    getResolvedMap().putAll( map );
542
  }
543
544
  private void initDefinitionPane() {
545
    openDefinitions( getDefinitionPath() );
546
  }
547
548
  /**
549
   * Called when an exception occurs that warrants the user's attention.
550
   *
551
   * @param e The exception with a message that the user should know about.
552
   */
553
  private void error( final Exception e ) {
554
    getNotifier().notify( e );
555
  }
556
557
  //---- File actions -------------------------------------------------------
558
559
  /**
560
   * Called when an {@link Observable} instance has changed. This is called
561
   * by both the {@link Snitch} service and the notify service. The @link
562
   * Snitch} service can be called for different file types, including
563
   * {@link DefinitionSource} instances.
564
   *
565
   * @param observable The observed instance.
566
   * @param value      The noteworthy item.
567
   */
568
  @Override
569
  public void update( final Observable observable, final Object value ) {
570
    if( value != null ) {
571
      if( observable instanceof Snitch && value instanceof Path ) {
572
        updateSelectedTab();
573
      }
574
      else if( observable instanceof Notifier && value instanceof String ) {
575
        updateStatusBar( (String) value );
576
      }
577
    }
578
  }
579
580
  /**
581
   * Updates the status bar to show the given message.
582
   *
583
   * @param s The message to show in the status bar.
584
   */
585
  private void updateStatusBar( final String s ) {
586
    runLater(
587
        () -> {
588
          final int index = s.indexOf( '\n' );
589
          final String message = s.substring(
590
              0, index > 0 ? index : s.length() );
591
592
          getStatusBar().setText( message );
593
        }
594
    );
595
  }
596
597
  /**
598
   * Called when a file has been modified.
599
   */
600
  private void updateSelectedTab() {
601
    runLater(
602
        () -> {
603
          // Brute-force XSLT file reload by re-instantiating all processors.
604
          resetProcessors();
605
          renderActiveTab();
606
        }
607
    );
608
  }
609
610
  /**
611
   * After resetting the processors, they will refresh anew to be up-to-date
612
   * with the files (text and definition) currently loaded into the editor.
613
   */
614
  private void resetProcessors() {
615
    getProcessors().clear();
616
  }
617
618
  //---- File actions -------------------------------------------------------
619
620
  private void fileNew() {
621
    getFileEditorPane().newEditor();
622
  }
623
624
  private void fileOpen() {
625
    getFileEditorPane().openFileDialog();
626
  }
627
628
  private void fileClose() {
629
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
630
  }
631
632
  /**
633
   * TODO: Upon closing, first remove the tab change listeners. (There's no
634
   * need to re-render each tab when all are being closed.)
635
   */
636
  private void fileCloseAll() {
637
    getFileEditorPane().closeAllEditors();
638
  }
639
640
  private void fileSave() {
641
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
642
  }
643
644
  private void fileSaveAs() {
645
    final FileEditorTab editor = getActiveFileEditorTab();
646
    getFileEditorPane().saveEditorAs( editor );
647
    getProcessors().remove( editor );
648
649
    try {
650
      process( editor );
651
    } catch( final Exception ex ) {
652
      getNotifier().notify( ex );
653
    }
654
  }
655
656
  private void fileSaveAll() {
657
    getFileEditorPane().saveAllEditors();
658
  }
659
660
  private void fileExit() {
661
    final Window window = getWindow();
662
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
663
  }
664
665
  //---- Edit actions -------------------------------------------------------
666
667
  /**
668
   * Transform the Markdown into HTML then copy that HTML into the copy
669
   * buffer.
670
   */
671
  private void copyHtml() {
672
    final var markdown = getActiveEditorPane().getText();
673
    final var processors = createProcessorFactory().createProcessors(
674
        getActiveFileEditorTab()
675
    );
676
677
    final var chain = processors.remove( HtmlPreviewProcessor.class );
678
679
    final String html = processChain( chain, markdown );
680
681
    final Clipboard clipboard = Clipboard.getSystemClipboard();
682
    final ClipboardContent content = new ClipboardContent();
683
    content.putString( html );
684
    clipboard.setContent( content );
685
  }
686
687
  /**
688
   * Used to find text in the active file editor window.
689
   */
690
  private void editFind() {
691
    final TextField input = getFindTextField();
692
    getStatusBar().setGraphic( input );
693
    input.requestFocus();
694
  }
695
696
  public void editFindNext() {
697
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
698
  }
699
700
  public void editPreferences() {
701
    getUserPreferences().show();
702
  }
703
704
  //---- Insert actions -----------------------------------------------------
705
706
  /**
707
   * Delegates to the active editor to handle wrapping the current text
708
   * selection with leading and trailing strings.
709
   *
710
   * @param leading  The string to put before the selection.
711
   * @param trailing The string to put after the selection.
712
   */
713
  private void insertMarkdown(
714
      final String leading, final String trailing ) {
715
    getActiveEditorPane().surroundSelection( leading, trailing );
716
  }
717
718
  private void insertMarkdown(
719
      final String leading, final String trailing, final String hint ) {
720
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
721
  }
722
723
  //---- Help actions -------------------------------------------------------
724
725
  private void helpAbout() {
726
    final Alert alert = new Alert( AlertType.INFORMATION );
727
    alert.setTitle( get( "Dialog.about.title" ) );
728
    alert.setHeaderText( get( "Dialog.about.header" ) );
729
    alert.setContentText( get( "Dialog.about.content" ) );
730
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
731
    alert.initOwner( getWindow() );
732
733
    alert.showAndWait();
734
  }
735
736
  //---- Member creators ----------------------------------------------------
737
738
  /**
739
   * Factory to create processors that are suited to different file types.
740
   *
741
   * @param tab The tab that is subjected to processing.
742
   * @return A processor suited to the file type specified by the tab's path.
743
   */
744
  private Processor<String> createProcessors( final FileEditorTab tab ) {
745
    return createProcessorFactory().createProcessors( tab );
746
  }
747
748
  private ProcessorFactory createProcessorFactory() {
749
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
750
  }
751
752
  private HTMLPreviewPane createHTMLPreviewPane() {
753
    return new HTMLPreviewPane();
754
  }
755
756
  private DefinitionSource createDefaultDefinitionSource() {
757
    return new YamlDefinitionSource( getDefinitionPath() );
758
  }
759
760
  private DefinitionSource createDefinitionSource( final Path path ) {
761
    try {
762
      return createDefinitionFactory().createDefinitionSource( path );
763
    } catch( final Exception ex ) {
764
      error( ex );
765
      return createDefaultDefinitionSource();
766
    }
767
  }
768
769
  private TextField createFindTextField() {
770
    return new TextField();
771
  }
772
773
  private DefinitionFactory createDefinitionFactory() {
774
    return new DefinitionFactory();
775
  }
776
777
  private StatusBar createStatusBar() {
778
    return new StatusBar();
779
  }
780
781
  private Scene createScene() {
782
    final SplitPane splitPane = new SplitPane(
783
        getDefinitionPane().getNode(),
784
        getFileEditorPane().getNode(),
785
        getPreviewPane().getNode() );
786
787
    splitPane.setDividerPositions(
788
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
789
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
790
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
791
792
    getDefinitionPane().prefHeightProperty()
793
                       .bind( splitPane.heightProperty() );
794
795
    final BorderPane borderPane = new BorderPane();
796
    borderPane.setPrefSize( 1024, 800 );
797
    borderPane.setTop( createMenuBar() );
798
    borderPane.setBottom( getStatusBar() );
799
    borderPane.setCenter( splitPane );
800
801
    final VBox statusBar = new VBox();
802
    statusBar.setAlignment( Pos.BASELINE_CENTER );
803
    statusBar.getChildren().add( getLineNumberText() );
804
    getStatusBar().getRightItems().add( statusBar );
805
806
    // Force preview pane refresh on Windows.
807
    splitPane.getDividers().get( 1 ).positionProperty().addListener(
808
        ( l, oValue, nValue ) -> runLater(
809
            () -> {
810
              if( SystemUtils.IS_OS_WINDOWS ) {
811
                getPreviewPane().getScrollPane().repaint();
812
              }
813
            }
814
        )
815
    );
816
817
    return new Scene( borderPane );
818
  }
819
820
  private Text createLineNumberText() {
821
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
822
  }
823
824
  private Node createMenuBar() {
825
    final BooleanBinding activeFileEditorIsNull =
826
        getFileEditorPane().activeFileEditorProperty().isNull();
827
828
    // File actions
829
    final Action fileNewAction = new ActionBuilder()
830
        .setText( "Main.menu.file.new" )
831
        .setAccelerator( "Shortcut+N" )
832
        .setIcon( FILE_ALT )
833
        .setAction( e -> fileNew() )
834
        .build();
835
    final Action fileOpenAction = new ActionBuilder()
836
        .setText( "Main.menu.file.open" )
837
        .setAccelerator( "Shortcut+O" )
838
        .setIcon( FOLDER_OPEN_ALT )
839
        .setAction( e -> fileOpen() )
840
        .build();
841
    final Action fileCloseAction = new ActionBuilder()
842
        .setText( "Main.menu.file.close" )
843
        .setAccelerator( "Shortcut+W" )
844
        .setAction( e -> fileClose() )
845
        .setDisable( activeFileEditorIsNull )
846
        .build();
847
    final Action fileCloseAllAction = new ActionBuilder()
848
        .setText( "Main.menu.file.close_all" )
849
        .setAction( e -> fileCloseAll() )
850
        .setDisable( activeFileEditorIsNull )
851
        .build();
852
    final Action fileSaveAction = new ActionBuilder()
853
        .setText( "Main.menu.file.save" )
854
        .setAccelerator( "Shortcut+S" )
855
        .setIcon( FLOPPY_ALT )
856
        .setAction( e -> fileSave() )
857
        .setDisable( createActiveBooleanProperty(
858
            FileEditorTab::modifiedProperty ).not() )
859
        .build();
860
    final Action fileSaveAsAction = new ActionBuilder()
861
        .setText( "Main.menu.file.save_as" )
862
        .setAction( e -> fileSaveAs() )
863
        .setDisable( activeFileEditorIsNull )
864
        .build();
865
    final Action fileSaveAllAction = new ActionBuilder()
866
        .setText( "Main.menu.file.save_all" )
867
        .setAccelerator( "Shortcut+Shift+S" )
868
        .setAction( e -> fileSaveAll() )
869
        .setDisable( Bindings.not(
870
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
871
        .build();
872
    final Action fileExitAction = new ActionBuilder()
873
        .setText( "Main.menu.file.exit" )
874
        .setAction( e -> fileExit() )
875
        .build();
876
877
    // Edit actions
878
    final Action editCopyHtmlAction = new ActionBuilder()
879
        .setText( Messages.get( "Main.menu.edit.copy.html" ) )
880
        .setIcon( HTML5 )
881
        .setAction( e -> copyHtml() )
882
        .setDisable( activeFileEditorIsNull )
883
        .build();
884
885
    final Action editUndoAction = new ActionBuilder()
886
        .setText( "Main.menu.edit.undo" )
887
        .setAccelerator( "Shortcut+Z" )
888
        .setIcon( UNDO )
889
        .setAction( e -> getActiveEditorPane().undo() )
890
        .setDisable( createActiveBooleanProperty(
891
            FileEditorTab::canUndoProperty ).not() )
892
        .build();
893
    final Action editRedoAction = new ActionBuilder()
894
        .setText( "Main.menu.edit.redo" )
895
        .setAccelerator( "Shortcut+Y" )
896
        .setIcon( REPEAT )
897
        .setAction( e -> getActiveEditorPane().redo() )
898
        .setDisable( createActiveBooleanProperty(
899
            FileEditorTab::canRedoProperty ).not() )
900
        .build();
901
902
    final Action editCutAction = new ActionBuilder()
903
        .setText( Messages.get( "Main.menu.edit.cut" ) )
904
        .setAccelerator( "Shortcut+X" )
905
        .setIcon( CUT )
906
        .setAction( e -> getActiveEditorPane().cut() )
907
        .setDisable( activeFileEditorIsNull )
908
        .build();
909
    final Action editCopyAction = new ActionBuilder()
910
        .setText( Messages.get( "Main.menu.edit.copy" ) )
911
        .setAccelerator( "Shortcut+C" )
912
        .setIcon( COPY )
913
        .setAction( e -> getActiveEditorPane().copy() )
914
        .setDisable( activeFileEditorIsNull )
915
        .build();
916
    final Action editPasteAction = new ActionBuilder()
917
        .setText( Messages.get( "Main.menu.edit.paste" ) )
918
        .setAccelerator( "Shortcut+V" )
919
        .setIcon( PASTE )
920
        .setAction( e -> getActiveEditorPane().paste() )
921
        .setDisable( activeFileEditorIsNull )
922
        .build();
923
    final Action editSelectAllAction = new ActionBuilder()
924
        .setText( Messages.get( "Main.menu.edit.selectAll" ) )
925
        .setAccelerator( "Shortcut+A" )
926
        .setAction( e -> getActiveEditorPane().selectAll() )
927
        .setDisable( activeFileEditorIsNull )
928
        .build();
929
930
    final Action editFindAction = new ActionBuilder()
931
        .setText( "Main.menu.edit.find" )
932
        .setAccelerator( "Ctrl+F" )
933
        .setIcon( SEARCH )
934
        .setAction( e -> editFind() )
935
        .setDisable( activeFileEditorIsNull )
936
        .build();
937
    final Action editFindNextAction = new ActionBuilder()
938
        .setText( "Main.menu.edit.find.next" )
939
        .setAccelerator( "F3" )
940
        .setIcon( null )
941
        .setAction( e -> editFindNext() )
942
        .setDisable( activeFileEditorIsNull )
943
        .build();
944
    final Action editPreferencesAction = new ActionBuilder()
945
        .setText( "Main.menu.edit.preferences" )
946
        .setAccelerator( "Ctrl+Alt+S" )
947
        .setAction( e -> editPreferences() )
948
        .build();
949
950
    // Insert actions
951
    final Action insertBoldAction = new ActionBuilder()
952
        .setText( "Main.menu.insert.bold" )
953
        .setAccelerator( "Shortcut+B" )
954
        .setIcon( BOLD )
955
        .setAction( e -> insertMarkdown( "**", "**" ) )
956
        .setDisable( activeFileEditorIsNull )
957
        .build();
958
    final Action insertItalicAction = new ActionBuilder()
959
        .setText( "Main.menu.insert.italic" )
960
        .setAccelerator( "Shortcut+I" )
961
        .setIcon( ITALIC )
962
        .setAction( e -> insertMarkdown( "*", "*" ) )
963
        .setDisable( activeFileEditorIsNull )
964
        .build();
965
    final Action insertSuperscriptAction = new ActionBuilder()
966
        .setText( "Main.menu.insert.superscript" )
967
        .setAccelerator( "Shortcut+[" )
968
        .setIcon( SUPERSCRIPT )
969
        .setAction( e -> insertMarkdown( "^", "^" ) )
970
        .setDisable( activeFileEditorIsNull )
971
        .build();
972
    final Action insertSubscriptAction = new ActionBuilder()
973
        .setText( "Main.menu.insert.subscript" )
974
        .setAccelerator( "Shortcut+]" )
975
        .setIcon( SUBSCRIPT )
976
        .setAction( e -> insertMarkdown( "~", "~" ) )
977
        .setDisable( activeFileEditorIsNull )
978
        .build();
979
    final Action insertStrikethroughAction = new ActionBuilder()
980
        .setText( "Main.menu.insert.strikethrough" )
981
        .setAccelerator( "Shortcut+T" )
982
        .setIcon( STRIKETHROUGH )
983
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
984
        .setDisable( activeFileEditorIsNull )
985
        .build();
986
    final Action insertBlockquoteAction = new ActionBuilder()
987
        .setText( "Main.menu.insert.blockquote" )
988
        .setAccelerator( "Ctrl+Q" )
989
        .setIcon( QUOTE_LEFT )
990
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
991
        .setDisable( activeFileEditorIsNull )
992
        .build();
993
    final Action insertCodeAction = new ActionBuilder()
994
        .setText( "Main.menu.insert.code" )
995
        .setAccelerator( "Shortcut+K" )
996
        .setIcon( CODE )
997
        .setAction( e -> insertMarkdown( "`", "`" ) )
998
        .setDisable( activeFileEditorIsNull )
999
        .build();
1000
    final Action insertFencedCodeBlockAction = new ActionBuilder()
1001
        .setText( "Main.menu.insert.fenced_code_block" )
1002
        .setAccelerator( "Shortcut+Shift+K" )
1003
        .setIcon( FILE_CODE_ALT )
1004
        .setAction( e -> insertMarkdown(
1005
            "\n\n```\n",
1006
            "\n```\n\n",
1007
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
1008
        .setDisable( activeFileEditorIsNull )
1009
        .build();
1010
    final Action insertLinkAction = new ActionBuilder()
1011
        .setText( "Main.menu.insert.link" )
1012
        .setAccelerator( "Shortcut+L" )
1013
        .setIcon( LINK )
1014
        .setAction( e -> getActiveEditorPane().insertLink() )
1015
        .setDisable( activeFileEditorIsNull )
1016
        .build();
1017
    final Action insertImageAction = new ActionBuilder()
1018
        .setText( "Main.menu.insert.image" )
1019
        .setAccelerator( "Shortcut+G" )
1020
        .setIcon( PICTURE_ALT )
1021
        .setAction( e -> getActiveEditorPane().insertImage() )
1022
        .setDisable( activeFileEditorIsNull )
1023
        .build();
1024
1025
    // Number of header actions (H1 ... H3)
1026
    final int HEADERS = 3;
1027
    final Action[] headers = new Action[ HEADERS ];
1028
1029
    for( int i = 1; i <= HEADERS; i++ ) {
1030
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
1031
      final String markup = String.format( "%n%n%s ", hashes );
1032
      final String text = "Main.menu.insert.header." + i;
1033
      final String accelerator = "Shortcut+" + i;
1034
      final String prompt = text + ".prompt";
1035
1036
      headers[ i - 1 ] = new ActionBuilder()
1037
          .setText( text )
1038
          .setAccelerator( accelerator )
1039
          .setIcon( HEADER )
1040
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
1041
          .setDisable( activeFileEditorIsNull )
1042
          .build();
1043
    }
1044
1045
    final Action insertUnorderedListAction = new ActionBuilder()
1046
        .setText( "Main.menu.insert.unordered_list" )
1047
        .setAccelerator( "Shortcut+U" )
1048
        .setIcon( LIST_UL )
1049
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
1050
        .setDisable( activeFileEditorIsNull )
1051
        .build();
1052
    final Action insertOrderedListAction = new ActionBuilder()
1053
        .setText( "Main.menu.insert.ordered_list" )
1054
        .setAccelerator( "Shortcut+Shift+O" )
1055
        .setIcon( LIST_OL )
1056
        .setAction( e -> insertMarkdown(
1057
            "\n\n1. ", "" ) )
1058
        .setDisable( activeFileEditorIsNull )
1059
        .build();
1060
    final Action insertHorizontalRuleAction = new ActionBuilder()
1061
        .setText( "Main.menu.insert.horizontal_rule" )
1062
        .setAccelerator( "Shortcut+H" )
1063
        .setAction( e -> insertMarkdown(
1064
            "\n\n---\n\n", "" ) )
1065
        .setDisable( activeFileEditorIsNull )
1066
        .build();
1067
1068
    // Help actions
1069
    final Action helpAboutAction = new ActionBuilder()
1070
        .setText( "Main.menu.help.about" )
1071
        .setAction( e -> helpAbout() )
1072
        .build();
1073
1074
    //---- MenuBar ----
1075
    final Menu fileMenu = ActionUtils.createMenu(
1076
        get( "Main.menu.file" ),
1077
        fileNewAction,
1078
        fileOpenAction,
1079
        null,
1080
        fileCloseAction,
1081
        fileCloseAllAction,
1082
        null,
1083
        fileSaveAction,
1084
        fileSaveAsAction,
1085
        fileSaveAllAction,
1086
        null,
1087
        fileExitAction );
1088
1089
    final Menu editMenu = ActionUtils.createMenu(
1090
        get( "Main.menu.edit" ),
1091
        editCopyHtmlAction,
1092
        null,
1093
        editUndoAction,
1094
        editRedoAction,
1095
        null,
1096
        editCutAction,
1097
        editCopyAction,
1098
        editPasteAction,
1099
        null,
1100
        editFindAction,
1101
        editFindNextAction,
1102
        null,
1103
        editPreferencesAction );
1104
1105
    final Menu insertMenu = ActionUtils.createMenu(
1106
        get( "Main.menu.insert" ),
1107
        insertBoldAction,
1108
        insertItalicAction,
1109
        insertSuperscriptAction,
1110
        insertSubscriptAction,
1111
        insertStrikethroughAction,
1112
        insertBlockquoteAction,
1113
        insertCodeAction,
1114
        insertFencedCodeBlockAction,
1115
        null,
1116
        insertLinkAction,
1117
        insertImageAction,
1118
        null,
1119
        headers[ 0 ],
1120
        headers[ 1 ],
1121
        headers[ 2 ],
1122
        null,
1123
        insertUnorderedListAction,
1124
        insertOrderedListAction,
1125
        insertHorizontalRuleAction
1126
    );
1127
1128
    final Menu helpMenu = ActionUtils.createMenu(
1129
        get( "Main.menu.help" ),
1130
        helpAboutAction );
1131
1132
    final MenuBar menuBar = new MenuBar(
1133
        fileMenu,
1134
        editMenu,
1135
        insertMenu,
1136
        helpMenu );
1137
1138
    //---- ToolBar ----
1139
    final ToolBar toolBar = ActionUtils.createToolBar(
1140
        fileNewAction,
1141
        fileOpenAction,
1142
        fileSaveAction,
1143
        null,
1144
        editUndoAction,
1145
        editRedoAction,
1146
        editCutAction,
1147
        editCopyAction,
1148
        editPasteAction,
10601149
        null,
10611150
        insertBoldAction,
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
3737
import javafx.event.EventHandler;
3838
import javafx.geometry.Insets;
39
import javafx.scene.Node;
40
import javafx.scene.control.*;
41
import javafx.scene.control.cell.TextFieldTreeCell;
42
import javafx.scene.input.KeyEvent;
43
import javafx.scene.layout.BorderPane;
44
import javafx.scene.layout.HBox;
45
import javafx.util.StringConverter;
46
47
import java.util.*;
48
49
import static com.scrivenvar.Messages.get;
50
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
51
import static javafx.geometry.Pos.CENTER;
52
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
53
54
/**
55
 * Provides the user interface that holds a {@link TreeView}, which
56
 * allows users to interact with key/value pairs loaded from the
57
 * {@link DocumentParser} and adapted using a {@link TreeAdapter}.
58
 */
59
public final class DefinitionPane extends TitledPane {
60
61
  /**
62
   * Trimmed off the end of a word to match a variable name.
63
   */
64
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
65
66
  /**
67
   * Contains a view of the definitions.
68
   */
69
  private final TreeView<String> mTreeView = new TreeView<>();
70
71
  /**
72
   * Handlers for key press events.
73
   */
74
  private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
75
      = new HashSet<>();
76
77
  /**
78
   * Definition file name shown in the title of the pane.
79
   */
80
  private final StringProperty mFilename = new SimpleStringProperty();
81
82
  /**
83
   * Constructs a definition pane with a given tree view root.
84
   */
85
  public DefinitionPane() {
86
    final var treeView = getTreeView();
87
    treeView.setEditable( true );
88
    treeView.setCellFactory( cell -> createTreeCell() );
89
    treeView.setContextMenu( createContextMenu() );
90
    treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
91
    treeView.setShowRoot( false );
92
    getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE );
93
94
    final var bCreate = createButton(
95
        "create", TREE, e -> addItem() );
96
    final var bRename = createButton(
97
        "rename", EDIT, e -> editSelectedItem() );
98
    final var bDelete = createButton(
99
        "delete", TRASH, e -> deleteSelectedItems() );
100
101
    final var buttonBar = new HBox();
102
    buttonBar.getChildren().addAll( bCreate, bRename, bDelete );
103
    buttonBar.setAlignment( CENTER );
104
    buttonBar.setSpacing( 10 );
105
106
    final var borderPane = new BorderPane();
107
    borderPane.setPadding( new Insets( 0, 0, 0, 0 ) );
108
    borderPane.setCenter( treeView );
109
    borderPane.setBottom( buttonBar );
110
111
    textProperty().bind( mFilename );
112
113
    setContent( borderPane );
114
    setCollapsible( false );
115
  }
116
117
  private Button createButton(
118
      final String msgKey,
119
      final FontAwesomeIcon icon,
120
      final EventHandler<ActionEvent> eventHandler ) {
121
    final var keyPrefix = "Pane.definition.button." + msgKey;
122
    final var button = new Button( get( keyPrefix + ".label" ) );
123
    button.setOnAction( eventHandler );
124
125
    button.setGraphic(
126
        FontAwesomeIconFactory.get().createIcon( icon )
127
    );
128
    button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) );
129
130
    return button;
131
  }
132
133
  /**
134
   * Changes the root of the {@link TreeView} to the root of the
135
   * {@link TreeView} from the {@link DefinitionSource}.
136
   *
137
   * @param definitionSource Container for the hierarchy of key/value pairs
138
   *                         to replace the existing hierarchy.
139
   */
140
  public void update( final DefinitionSource definitionSource ) {
141
    assert definitionSource != null;
142
143
    final TreeAdapter treeAdapter = definitionSource.getTreeAdapter();
144
    final TreeItem<String> root = treeAdapter.adapt(
145
        get( "Pane.definition.node.root.title" )
146
    );
147
148
    getTreeView().setRoot( root );
149
  }
150
151
  public Map<String, String> toMap() {
152
    return TreeItemAdapter.toMap( getTreeView().getRoot() );
153
  }
154
155
  /**
156
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
157
   * is modified. The modifications include: item value changes, item additions,
158
   * and item removals.
159
   * <p>
160
   * Safe to call multiple times; if a handler is already registered, the
161
   * old handler is used.
162
   * </p>
163
   *
164
   * @param handler The handler to call whenever any {@link TreeItem} changes.
165
   */
166
  public void addTreeChangeHandler(
167
      final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
168
    final TreeItem<String> root = getTreeView().getRoot();
169
    root.addEventHandler( TreeItem.valueChangedEvent(), handler );
170
    root.addEventHandler( TreeItem.childrenModificationEvent(), handler );
171
  }
172
173
  public void addKeyEventHandler(
174
      final EventHandler<? super KeyEvent> handler ) {
175
    getKeyEventHandlers().add( handler );
176
  }
177
178
  /**
179
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
180
   * well-formed for export. A tree is considered well-formed if the following
181
   * conditions are met:
182
   *
183
   * <ul>
184
   *   <li>The root node contains at least one child node having a leaf.</li>
185
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
186
   * </ul>
187
   *
188
   * @return {@code null} if the document is well-formed, otherwise the
189
   * problematic child {@link TreeItem}.
190
   */
191
  public TreeItem<String> isTreeWellFormed() {
192
    final var root = getTreeView().getRoot();
193
194
    for( final var child : root.getChildren() ) {
195
      final var problemChild = isWellFormed( child );
196
197
      if( child.isLeaf() || problemChild != null ) {
198
        return problemChild;
199
      }
200
    }
201
202
    return null;
203
  }
204
205
  /**
206
   * Determines whether the document is well-formed by ensuring that
207
   * child branches do not contain multiple leaves.
208
   *
209
   * @param item The sub-tree to check for well-formedness.
210
   * @return {@code null} when the tree is well-formed, otherwise the
211
   * problematic {@link TreeItem}.
212
   */
213
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
214
    int childLeafs = 0;
215
    int childBranches = 0;
216
217
    for( final TreeItem<String> child : item.getChildren() ) {
218
      if( child.isLeaf() ) {
219
        childLeafs++;
220
      }
221
      else {
222
        childBranches++;
223
      }
224
225
      final var problemChild = isWellFormed( child );
226
227
      if( problemChild != null ) {
228
        return problemChild;
229
      }
230
    }
231
232
    return ((childBranches > 0 && childLeafs == 0) ||
233
        (childBranches == 0 && childLeafs <= 1)) ? null : item;
234
  }
235
236
  /**
237
   * Returns the leaf that matches the given value. If the value is terminally
238
   * punctuated, the punctuation is removed if no match was found.
239
   *
240
   * @param value    The value to find, never null.
241
   * @param findMode Defines how to match words.
242
   * @return The leaf that contains the given value, or null if neither the
243
   * original value nor the terminally-trimmed value was found.
244
   */
245
  public VariableTreeItem<String> findLeaf(
246
      final String value, final FindMode findMode ) {
247
    final var root = getTreeRoot();
248
    final var leaf = root.findLeaf( value, findMode );
249
250
    return leaf == null
251
        ? root.findLeaf( rtrimTerminalPunctuation( value ) )
252
        : leaf;
253
  }
254
255
  /**
256
   * Removes punctuation from the end of a string.
257
   *
258
   * @param s The string to trim, never null.
259
   * @return The string trimmed of all terminal characters from the end
260
   */
261
  private String rtrimTerminalPunctuation( final String s ) {
262
    assert s != null;
263
    int index = s.length() - 1;
264
265
    while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) {
266
      index--;
267
    }
268
269
    return s.substring( 0, index );
270
  }
271
272
  /**
273
   * Expands the node to the root, recursively.
274
   *
275
   * @param <T>  The type of tree item to expand (usually String).
276
   * @param node The node to expand.
277
   */
278
  public <T> void expand( final TreeItem<T> node ) {
279
    if( node != null ) {
280
      expand( node.getParent() );
281
282
      if( !node.isLeaf() ) {
283
        node.setExpanded( true );
284
      }
285
    }
286
  }
287
288
  public void select( final TreeItem<String> item ) {
289
    getSelectionModel().clearSelection();
290
    getSelectionModel().select( getTreeView().getRow( item ) );
291
  }
292
293
  /**
294
   * Collapses the tree, recursively.
295
   */
296
  public void collapse() {
297
    collapse( getTreeRoot().getChildren() );
298
  }
299
300
  /**
301
   * Collapses the tree, recursively.
302
   *
303
   * @param <T>   The type of tree item to expand (usually String).
304
   * @param nodes The nodes to collapse.
305
   */
306
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
307
    for( final var node : nodes ) {
308
      node.setExpanded( false );
309
      collapse( node.getChildren() );
310
    }
311
  }
312
313
  /**
314
   * @return {@code true} when the user is editing a {@link TreeItem}.
315
   */
316
  private boolean isEditingTreeItem() {
317
    return getTreeView().editingItemProperty().getValue() != null;
318
  }
319
320
  /**
321
   * Changes to edit mode for the selected item.
322
   */
323
  private void editSelectedItem() {
324
    getTreeView().edit( getSelectedItem() );
325
  }
326
327
  /**
328
   * Removes all selected items from the {@link TreeView}.
329
   */
330
  private void deleteSelectedItems() {
331
    for( final var item : getSelectedItems() ) {
332
      final var parent = item.getParent();
333
334
      if( parent != null ) {
335
        parent.getChildren().remove( item );
336
      }
337
    }
338
  }
339
340
  /**
341
   * Deletes the selected item.
342
   */
343
  private void deleteSelectedItem() {
344
    final var c = getSelectedItem();
345
    getSiblings( c ).remove( c );
346
  }
347
348
  /**
349
   * Adds a new item under the selected item (or root if nothing is selected).
350
   * There are a few conditions to consider: when adding to the root,
351
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
352
   * root must contain two items: a key and a value.
353
   */
354
  private void addItem() {
355
    final var value = createTreeItem();
356
    getSelectedItem().getChildren().add( value );
357
    expand( value );
358
    select( value );
359
  }
360
361
  private ContextMenu createContextMenu() {
362
    final ContextMenu menu = new ContextMenu();
363
    final ObservableList<MenuItem> items = menu.getItems();
364
365
    addMenuItem( items, "Definition.menu.create" )
366
        .setOnAction( e -> addItem() );
367
368
    addMenuItem( items, "Definition.menu.rename" )
369
        .setOnAction( e -> editSelectedItem() );
370
371
    addMenuItem( items, "Definition.menu.remove" )
372
        .setOnAction( e -> deleteSelectedItem() );
373
374
    return menu;
375
  }
376
377
  /**
378
   * Executes hot-keys for edits to the definition tree.
379
   *
380
   * @param event Contains the key code of the key that was pressed.
381
   */
382
  private void keyEventFilter( final KeyEvent event ) {
383
    if( !isEditingTreeItem() ) {
384
      switch( event.getCode() ) {
385
        case ENTER:
386
          expand( getSelectedItem() );
387
          event.consume();
388
          break;
389
390
        case DELETE:
391
          deleteSelectedItems();
392
          break;
393
394
        case INSERT:
395
          addItem();
396
          break;
397
398
        case R:
399
          if( event.isControlDown() ) {
400
            editSelectedItem();
401
          }
402
403
          break;
404
      }
405
406
      for( final var handler : getKeyEventHandlers() ) {
407
        handler.handle( event );
408
      }
409
    }
410
  }
411
412
  /**
413
   * Adds a menu item to a list of menu items.
414
   *
415
   * @param items    The list of menu items to append to.
416
   * @param labelKey The resource bundle key name for the menu item's label.
417
   * @return The menu item added to the list of menu items.
418
   */
419
  private MenuItem addMenuItem(
420
      final List<MenuItem> items, final String labelKey ) {
421
    final MenuItem menuItem = createMenuItem( labelKey );
422
    items.add( menuItem );
423
    return menuItem;
424
  }
425
426
  private MenuItem createMenuItem( final String labelKey ) {
427
    return new MenuItem( get( labelKey ) );
428
  }
429
430
  private VariableTreeItem<String> createTreeItem() {
431
    return new VariableTreeItem<>( get( "Definition.menu.add.default" ) );
432
  }
433
434
  private TreeCell<String> createTreeCell() {
435
    return new TextFieldTreeCell<>(
436
        createStringConverter() ) {
39
import javafx.geometry.Pos;
40
import javafx.scene.Node;
41
import javafx.scene.control.*;
42
import javafx.scene.control.cell.TextFieldTreeCell;
43
import javafx.scene.input.KeyEvent;
44
import javafx.scene.layout.BorderPane;
45
import javafx.scene.layout.HBox;
46
import javafx.util.StringConverter;
47
48
import java.util.*;
49
50
import static com.scrivenvar.Messages.get;
51
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
52
import static javafx.geometry.Pos.CENTER;
53
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
54
55
/**
56
 * Provides the user interface that holds a {@link TreeView}, which
57
 * allows users to interact with key/value pairs loaded from the
58
 * {@link DocumentParser} and adapted using a {@link TreeAdapter}.
59
 */
60
public final class DefinitionPane extends BorderPane {
61
62
  /**
63
   * Trimmed off the end of a word to match a variable name.
64
   */
65
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
66
67
  /**
68
   * Contains a view of the definitions.
69
   */
70
  private final TreeView<String> mTreeView = new TreeView<>();
71
72
  /**
73
   * Handlers for key press events.
74
   */
75
  private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
76
      = new HashSet<>();
77
78
  /**
79
   * Definition file name shown in the title of the pane.
80
   */
81
  private final StringProperty mFilename = new SimpleStringProperty();
82
83
  private final TitledPane mTitledPane = new TitledPane();
84
85
  /**
86
   * Constructs a definition pane with a given tree view root.
87
   */
88
  public DefinitionPane() {
89
    final var treeView = getTreeView();
90
    treeView.setEditable( true );
91
    treeView.setCellFactory( cell -> createTreeCell() );
92
    treeView.setContextMenu( createContextMenu() );
93
    treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
94
    treeView.setShowRoot( false );
95
    getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE );
96
97
    final var bCreate = createButton(
98
        "create", TREE, e -> addItem() );
99
    final var bRename = createButton(
100
        "rename", EDIT, e -> editSelectedItem() );
101
    final var bDelete = createButton(
102
        "delete", TRASH, e -> deleteSelectedItems() );
103
104
    final var buttonBar = new HBox();
105
    buttonBar.getChildren().addAll( bCreate, bRename, bDelete );
106
    buttonBar.setAlignment( CENTER );
107
    buttonBar.setSpacing( 10 );
108
109
    final var titledPane = getTitledPane();
110
    titledPane.textProperty().bind( mFilename );
111
    titledPane.setContent( treeView );
112
    titledPane.setCollapsible( false );
113
    titledPane.setPadding( new Insets( 0, 0, 0, 0 ) );
114
115
    setTop( buttonBar );
116
    setCenter( titledPane );
117
    setAlignment( buttonBar, Pos.TOP_CENTER );
118
    setAlignment( titledPane, Pos.TOP_CENTER );
119
120
    titledPane.prefHeightProperty().bind( this.heightProperty() );
121
  }
122
123
  public void setTooltip( final Tooltip tooltip ) {
124
    getTitledPane().setTooltip( tooltip );
125
  }
126
127
  private TitledPane getTitledPane() {
128
    return mTitledPane;
129
  }
130
131
  private Button createButton(
132
      final String msgKey,
133
      final FontAwesomeIcon icon,
134
      final EventHandler<ActionEvent> eventHandler ) {
135
    final var keyPrefix = "Pane.definition.button." + msgKey;
136
    final var button = new Button( get( keyPrefix + ".label" ) );
137
    button.setOnAction( eventHandler );
138
139
    button.setGraphic(
140
        FontAwesomeIconFactory.get().createIcon( icon )
141
    );
142
    button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) );
143
144
    return button;
145
  }
146
147
  /**
148
   * Changes the root of the {@link TreeView} to the root of the
149
   * {@link TreeView} from the {@link DefinitionSource}.
150
   *
151
   * @param definitionSource Container for the hierarchy of key/value pairs
152
   *                         to replace the existing hierarchy.
153
   */
154
  public void update( final DefinitionSource definitionSource ) {
155
    assert definitionSource != null;
156
157
    final TreeAdapter treeAdapter = definitionSource.getTreeAdapter();
158
    final TreeItem<String> root = treeAdapter.adapt(
159
        get( "Pane.definition.node.root.title" )
160
    );
161
162
    getTreeView().setRoot( root );
163
  }
164
165
  public Map<String, String> toMap() {
166
    return TreeItemAdapter.toMap( getTreeView().getRoot() );
167
  }
168
169
  /**
170
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
171
   * is modified. The modifications include: item value changes, item additions,
172
   * and item removals.
173
   * <p>
174
   * Safe to call multiple times; if a handler is already registered, the
175
   * old handler is used.
176
   * </p>
177
   *
178
   * @param handler The handler to call whenever any {@link TreeItem} changes.
179
   */
180
  public void addTreeChangeHandler(
181
      final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
182
    final TreeItem<String> root = getTreeView().getRoot();
183
    root.addEventHandler( TreeItem.valueChangedEvent(), handler );
184
    root.addEventHandler( TreeItem.childrenModificationEvent(), handler );
185
  }
186
187
  public void addKeyEventHandler(
188
      final EventHandler<? super KeyEvent> handler ) {
189
    getKeyEventHandlers().add( handler );
190
  }
191
192
  /**
193
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
194
   * well-formed for export. A tree is considered well-formed if the following
195
   * conditions are met:
196
   *
197
   * <ul>
198
   *   <li>The root node contains at least one child node having a leaf.</li>
199
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
200
   * </ul>
201
   *
202
   * @return {@code null} if the document is well-formed, otherwise the
203
   * problematic child {@link TreeItem}.
204
   */
205
  public TreeItem<String> isTreeWellFormed() {
206
    final var root = getTreeView().getRoot();
207
208
    for( final var child : root.getChildren() ) {
209
      final var problemChild = isWellFormed( child );
210
211
      if( child.isLeaf() || problemChild != null ) {
212
        return problemChild;
213
      }
214
    }
215
216
    return null;
217
  }
218
219
  /**
220
   * Determines whether the document is well-formed by ensuring that
221
   * child branches do not contain multiple leaves.
222
   *
223
   * @param item The sub-tree to check for well-formedness.
224
   * @return {@code null} when the tree is well-formed, otherwise the
225
   * problematic {@link TreeItem}.
226
   */
227
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
228
    int childLeafs = 0;
229
    int childBranches = 0;
230
231
    for( final TreeItem<String> child : item.getChildren() ) {
232
      if( child.isLeaf() ) {
233
        childLeafs++;
234
      }
235
      else {
236
        childBranches++;
237
      }
238
239
      final var problemChild = isWellFormed( child );
240
241
      if( problemChild != null ) {
242
        return problemChild;
243
      }
244
    }
245
246
    return ((childBranches > 0 && childLeafs == 0) ||
247
        (childBranches == 0 && childLeafs <= 1)) ? null : item;
248
  }
249
250
  /**
251
   * Returns the leaf that matches the given value. If the value is terminally
252
   * punctuated, the punctuation is removed if no match was found.
253
   *
254
   * @param value    The value to find, never null.
255
   * @param findMode Defines how to match words.
256
   * @return The leaf that contains the given value, or null if neither the
257
   * original value nor the terminally-trimmed value was found.
258
   */
259
  public VariableTreeItem<String> findLeaf(
260
      final String value, final FindMode findMode ) {
261
    final var root = getTreeRoot();
262
    final var leaf = root.findLeaf( value, findMode );
263
264
    return leaf == null
265
        ? root.findLeaf( rtrimTerminalPunctuation( value ) )
266
        : leaf;
267
  }
268
269
  /**
270
   * Removes punctuation from the end of a string.
271
   *
272
   * @param s The string to trim, never null.
273
   * @return The string trimmed of all terminal characters from the end
274
   */
275
  private String rtrimTerminalPunctuation( final String s ) {
276
    assert s != null;
277
    int index = s.length() - 1;
278
279
    while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) {
280
      index--;
281
    }
282
283
    return s.substring( 0, index );
284
  }
285
286
  /**
287
   * Expands the node to the root, recursively.
288
   *
289
   * @param <T>  The type of tree item to expand (usually String).
290
   * @param node The node to expand.
291
   */
292
  public <T> void expand( final TreeItem<T> node ) {
293
    if( node != null ) {
294
      expand( node.getParent() );
295
296
      if( !node.isLeaf() ) {
297
        node.setExpanded( true );
298
      }
299
    }
300
  }
301
302
  public void select( final TreeItem<String> item ) {
303
    getSelectionModel().clearSelection();
304
    getSelectionModel().select( getTreeView().getRow( item ) );
305
  }
306
307
  /**
308
   * Collapses the tree, recursively.
309
   */
310
  public void collapse() {
311
    collapse( getTreeRoot().getChildren() );
312
  }
313
314
  /**
315
   * Collapses the tree, recursively.
316
   *
317
   * @param <T>   The type of tree item to expand (usually String).
318
   * @param nodes The nodes to collapse.
319
   */
320
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
321
    for( final var node : nodes ) {
322
      node.setExpanded( false );
323
      collapse( node.getChildren() );
324
    }
325
  }
326
327
  /**
328
   * @return {@code true} when the user is editing a {@link TreeItem}.
329
   */
330
  private boolean isEditingTreeItem() {
331
    return getTreeView().editingItemProperty().getValue() != null;
332
  }
333
334
  /**
335
   * Changes to edit mode for the selected item.
336
   */
337
  private void editSelectedItem() {
338
    getTreeView().edit( getSelectedItem() );
339
  }
340
341
  /**
342
   * Removes all selected items from the {@link TreeView}.
343
   */
344
  private void deleteSelectedItems() {
345
    for( final var item : getSelectedItems() ) {
346
      final var parent = item.getParent();
347
348
      if( parent != null ) {
349
        parent.getChildren().remove( item );
350
      }
351
    }
352
  }
353
354
  /**
355
   * Deletes the selected item.
356
   */
357
  private void deleteSelectedItem() {
358
    final var c = getSelectedItem();
359
    getSiblings( c ).remove( c );
360
  }
361
362
  /**
363
   * Adds a new item under the selected item (or root if nothing is selected).
364
   * There are a few conditions to consider: when adding to the root,
365
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
366
   * root must contain two items: a key and a value.
367
   */
368
  private void addItem() {
369
    final var value = createTreeItem();
370
    getSelectedItem().getChildren().add( value );
371
    expand( value );
372
    select( value );
373
  }
374
375
  private ContextMenu createContextMenu() {
376
    final ContextMenu menu = new ContextMenu();
377
    final ObservableList<MenuItem> items = menu.getItems();
378
379
    addMenuItem( items, "Definition.menu.create" )
380
        .setOnAction( e -> addItem() );
381
382
    addMenuItem( items, "Definition.menu.rename" )
383
        .setOnAction( e -> editSelectedItem() );
384
385
    addMenuItem( items, "Definition.menu.remove" )
386
        .setOnAction( e -> deleteSelectedItem() );
387
388
    return menu;
389
  }
390
391
  /**
392
   * Executes hot-keys for edits to the definition tree.
393
   *
394
   * @param event Contains the key code of the key that was pressed.
395
   */
396
  private void keyEventFilter( final KeyEvent event ) {
397
    if( !isEditingTreeItem() ) {
398
      switch( event.getCode() ) {
399
        case ENTER:
400
          expand( getSelectedItem() );
401
          event.consume();
402
          break;
403
404
        case DELETE:
405
          deleteSelectedItems();
406
          break;
407
408
        case INSERT:
409
          addItem();
410
          break;
411
412
        case R:
413
          if( event.isControlDown() ) {
414
            editSelectedItem();
415
          }
416
417
          break;
418
      }
419
420
      for( final var handler : getKeyEventHandlers() ) {
421
        handler.handle( event );
422
      }
423
    }
424
  }
425
426
  /**
427
   * Adds a menu item to a list of menu items.
428
   *
429
   * @param items    The list of menu items to append to.
430
   * @param labelKey The resource bundle key name for the menu item's label.
431
   * @return The menu item added to the list of menu items.
432
   */
433
  private MenuItem addMenuItem(
434
      final List<MenuItem> items, final String labelKey ) {
435
    final MenuItem menuItem = createMenuItem( labelKey );
436
    items.add( menuItem );
437
    return menuItem;
438
  }
439
440
  private MenuItem createMenuItem( final String labelKey ) {
441
    return new MenuItem( get( labelKey ) );
442
  }
443
444
  private VariableTreeItem<String> createTreeItem() {
445
    return new VariableTreeItem<>( get( "Definition.menu.add.default" ) );
446
  }
447
448
  private TreeCell<String> createTreeCell() {
449
    return new TextFieldTreeCell<>( createStringConverter() ) {
437450
      @Override
438451
      public void commitEdit( final String newValue ) {
M src/main/java/com/scrivenvar/editors/EditorPane.java
4141
4242
import java.nio.file.Path;
43
import java.util.Random;
4443
import java.util.function.Consumer;
4544
...
7372
  public void redo() {
7473
    getUndoManager().redo();
74
  }
75
76
  public void cut() {
77
    getEditor().selectParagraph();
78
    getEditor().cut();
79
  }
80
81
  public void copy() {
82
    getEditor().copy();
83
  }
84
85
  public void paste() {
86
    getEditor().paste();
87
  }
88
89
  public void selectAll() {
90
    getEditor().selectAll();
7591
  }
7692
M src/main/java/com/scrivenvar/editors/markdown/MarkdownEditorPane.java
3939
import javafx.scene.control.Dialog;
4040
import javafx.scene.control.IndexRange;
41
import javafx.scene.input.KeyCode;
4142
import javafx.scene.input.KeyEvent;
4243
import javafx.stage.Window;
4344
import org.fxmisc.richtext.StyleClassedTextArea;
4445
4546
import java.nio.file.Path;
47
import java.util.ArrayList;
48
import java.util.List;
4649
import java.util.regex.Matcher;
4750
import java.util.regex.Pattern;
4851
4952
import static com.scrivenvar.Constants.STYLESHEET_MARKDOWN;
5053
import static com.scrivenvar.util.Utils.ltrim;
5154
import static com.scrivenvar.util.Utils.rtrim;
5255
import static javafx.scene.input.KeyCode.ENTER;
56
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
5357
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
5458
5559
/**
5660
 * Markdown editor pane.
5761
 */
5862
public class MarkdownEditorPane extends EditorPane {
59
  private static final Pattern AUTO_INDENT_PATTERN = Pattern.compile(
63
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
6064
      "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
65
66
  /**
67
   * Any of these followed by a space and a letter produce a line
68
   * by themselves. The ">" need not be followed by a space.
69
   */
70
  private static final Pattern PATTERN_NEW_LINE = Pattern.compile(
71
      "^>|(((#+)|([*+\\-])|([1-9]\\.))\\s+).+" );
6172
6273
  public MarkdownEditorPane() {
...
7283
7384
    addKeyboardListener( keyPressed( ENTER ), this::enterPressed );
85
    addKeyboardListener( keyPressed( KeyCode.X, CONTROL_DOWN ), this::cut );
7486
  }
7587
...
100112
   * </p>
101113
   *
114
   * @param paraIndex The paragraph index from the editor pane to scroll to
115
   *                  in the preview pane, which  will be approximated if an
116
   *                  equivalent cannot be found.
102117
   * @return A unique identifier that correlates to an equivalent paragraph
103118
   * number once the Markdown is rendered into HTML.
104119
   */
105120
  public int approximateParagraphId( final int paraIndex ) {
106121
    final StyleClassedTextArea editor = getEditor();
107
    int i = 0, paragraph = 0;
122
    final List<String> lines = new ArrayList<>( 4096 );
108123
109
    while( i < paraIndex ) {
110
      // Reduce numerously nested blockquotes to blanks for isBlank call.
111
      final String text = editor.getParagraph( i++ )
112
                                .getText()
113
                                .replace( '>', ' ' );
124
    int i = 0;
125
    String prevText = "";
126
    boolean withinFencedBlock = false;
127
    boolean withinCodeBlock = false;
114128
115
      paragraph += text.isBlank() ? 0 : 1;
129
    for( final var p : editor.getParagraphs() ) {
130
      if( i > paraIndex ) {
131
        break;
132
      }
133
134
      final String text = p.getText().replace( '>', ' ' );
135
      if( text.startsWith( "```" ) ) {
136
        if( withinFencedBlock = !withinFencedBlock ) {
137
          lines.add( text );
138
        }
139
      }
140
141
      if( !withinFencedBlock ) {
142
        final boolean foundCodeBlock = text.startsWith( "    " );
143
144
        if( foundCodeBlock && !withinCodeBlock ) {
145
          lines.add( text );
146
          withinCodeBlock = true;
147
        }
148
        else if( !foundCodeBlock ) {
149
          withinCodeBlock = false;
150
        }
151
      }
152
153
      if( !withinFencedBlock && !withinCodeBlock &&
154
          ((!text.isBlank() && prevText.isBlank()) ||
155
              PATTERN_NEW_LINE.matcher( text ).matches()) ) {
156
        lines.add( text );
157
      }
158
159
      prevText = text;
160
      i++;
116161
    }
117162
118
    return paragraph;
163
    // Scrolling index is 1-based.
164
    return Math.max( lines.size() - 1, 0 );
119165
  }
120166
...
128174
  }
129175
176
  /**
177
   * @param leading  Characters to insert at the beginning of the current
178
   *                 selection (or paragraph).
179
   * @param trailing Characters to insert at the end of the current selection
180
   *                 (or paragraph).
181
   */
130182
  public void surroundSelection( final String leading, final String trailing ) {
131183
    surroundSelection( leading, trailing, null );
132184
  }
133185
186
  /**
187
   * @param leading  Characters to insert at the beginning of the current
188
   *                 selection (or paragraph).
189
   * @param trailing Characters to insert at the end of the current selection
190
   *                 (or paragraph).
191
   * @param hint     Instructional text inserted within the leading and
192
   *                 trailing characters, provided no text is selected.
193
   */
134194
  public void surroundSelection(
135195
      String leading, String trailing, final String hint ) {
...
218278
    final String currentLine =
219279
        textArea.getText( textArea.getCurrentParagraph() );
220
    final Matcher matcher = AUTO_INDENT_PATTERN.matcher( currentLine );
280
    final Matcher matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
221281
222282
    String newText = "\n";
...
242302
    // the pane.
243303
    textArea.requestFollowCaret();
304
  }
305
306
  private void cut( final KeyEvent event ) {
307
    super.cut();
244308
  }
245309
M src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
159159
      + "</head>"
160160
      + "<body>";
161
  private final static String HTML_FOOTER = "</body></html>";
161
162
  // Provide some extra space at the end for scrolling past the last line.
163
  private final static String HTML_FOOTER =
164
      "<p style='height=2em'>&nbsp;</p></body></html>";
162165
163166
  private final static W3CDom W3C_DOM = new W3CDom();
...
222225
   * @param html The new HTML document to display.
223226
   */
224
  public void update( final String html ) {
227
  public void process( final String html ) {
225228
    final Document jsoupDoc = Jsoup.parse( decorate( html ) );
226229
    final org.w3c.dom.Document w3cDoc = W3C_DOM.fromJsoup( jsoupDoc );
227230
228231
    mHtmlRenderer.setDocument( w3cDoc, getBaseUrl(), NS_HANDLER );
232
  }
233
234
  public void clear() {
235
    process( "" );
229236
  }
230237
M src/main/java/com/scrivenvar/preview/SVGRasterizer.java
8484
  static {
8585
    // A FontAwesome camera icon, cleft asunder.
86
    final String BROKEN_IMAGE_SVG = "<svg height='79pt' viewBox='0 0 100 79' " +
87
        "width='100pt' xmlns='http://www.w3.org/2000/svg'><g " +
88
        "fill='#454545'><path d='m32.175781 46.207031c1.316407 6.023438 6" +
89
        ".628907 10.4375 12.847657 10.675781zm0 0'/><path d='m27.167969 40" +
90
        ".105469-1.195313.949219.96875.804687c.050782-.59375.125-1.175781" +
91
        ".226563-1.753906zm0 0'/><path d='m42.394531 3.949219-10.054687" +
92
        ".875c-3.105469.269531-5.71875 2.414062-6.546875 5.382812l-1.464844 5" +
93
        ".222657-13.660156 1.183593c-4.113281.355469-7.160157 3.949219-6" +
94
        ".800781 8.023438l3.910156 44.269531c.363281 4.070312 3.992187 7" +
95
        ".085938 8.105468 6.730469l46.832032-4.058594-12.457032-10.347656c-" +
96
        ".992187.253906-2.007812.453125-3.0625.542969-10.277343.890624-19" +
97
        ".359374-6.65625-20.261718-16.832032-.089844-1.042968-.070313-2" +
98
        ".070312.007812-3.082031l-.96875-.804687 1.195313-.949219c1.4375-8" +
99
        ".042969 8.160156-14.476563 16.765625-15.222657.835937-.074218 1" +
100
        ".660156-.070312 2.476562-.035156l3.726563-2.953125zm0 0'/><path " +
101
        "d='m40.9375 46.152344 11.859375 11.742187c.570313.070313 1.144531" +
102
        ".121094 1.730469.121094 7.558594 0 13.714844-6.09375 13.714844-13" +
103
        ".578125 0-7.480469-6.15625-13.578125-13.714844-13.578125s-13.714844 " +
104
        "6.097656-13.714844 13.578125c0 .582031.050781 1.152344.125 1" +
105
        ".714844zm0 0'/><path d='m57.953125 3.363281 4.472656 19-4.183593 2" +
106
        ".269531c9.007812 1.988282 15.382812 10.316407 14.554687 19.664063-" +
107
        ".804687 9.132813-8.207031 16.128906-17.148437 16.824219l10.453124 12" +
108
        ".335937 17.75 1.539063c4.113282.355468 7.742188-2.660156 8.101563-6" +
109
        ".734375l3.910156-44.265625c.363281-4.074219-2.683593-7.667969-6" +
110
        ".796875-8.023438l-13.660156-1.183594-1.480469-5.226562c-.832031-2" +
111
        ".96875-3.441406-5.113281-6.546875-5.382812zm0 0'/></g></svg>";
86
    final String BROKEN_IMAGE_SVG = "<svg height='19pt' viewBox='0 0 25 19' " +
87
        "width='25pt' xmlns='http://www.w3.org/2000/svg'><g " +
88
        "fill='#454545'><path d='m8.042969 11.085938c.332031 1.445312 1" +
89
        ".660156 2.503906 3.214843 2.558593zm0 0'/><path d='m6.792969 9" +
90
        ".621094-.300781.226562.242187.195313c.015625-.144531.03125-.28125" +
91
        ".058594-.421875zm0 0'/><path d='m10.597656.949219-2.511718.207031c-" +
92
        ".777344.066406-1.429688.582031-1.636719 1.292969l-.367188 1.253906-3" +
93
        ".414062.28125c-1.027344.085937-1.792969.949219-1.699219 1.925781l" +
94
        ".976562 10.621094c.089844.976562.996094 1.699219 2.023438 1" +
95
        ".613281l11.710938-.972656-3.117188-2.484375c-.246094.0625-.5.109375-" +
96
        ".765625.132812-2.566406.210938-4.835937-1.597656-5.0625-4.039062-" +
97
        ".023437-.25-.019531-.496094 0-.738281l-.242187-.195313.300781-" +
98
        ".226562c.359375-1.929688 2.039062-3.472656 4.191406-3.652344.207031-" +
99
        ".015625.414063-.015625.617187-.007812l.933594-.707032zm0 0'/><path " +
100
        "d='m10.234375 11.070312 2.964844 2.820313c.144531.015625.285156" +
101
        ".027344.433593.027344 1.890626 0 3.429688-1.460938 3.429688-3.257813" +
102
        " 0-1.792968-1.539062-3.257812-3.429688-3.257812-1.890624 0-3.429687 " +
103
        "1.464844-3.429687 3.257812 0 .140625.011719.277344.03125.410156zm0 " +
104
        "0'/><path d='m14.488281.808594 1.117188 4.554687-1.042969.546875c2" +
105
        ".25.476563 3.84375 2.472656 3.636719 4.714844-.199219 2.191406-2" +
106
        ".050781 3.871094-4.285157 4.039062l2.609376 2.957032 4.4375.371094c1" +
107
        ".03125.085937 1.9375-.640626 2.027343-1.617188l.976563-10.617188c" +
108
        ".089844-.980468-.667969-1.839843-1.699219-1.925781l-3.414063-" +
109
        ".285156-.371093-1.253906c-.207031-.710938-.859375-1.226563-1" +
110
        ".636719-1.289063zm0 0'/></g></svg>";
112111
113112
    // The width and height cannot be embedded in the SVG above because the
114113
    // path element values are relative to the viewBox dimensions.
115
    final int w = 150;
116
    final int h = 150;
114
    final int w = 75;
115
    final int h = 75;
117116
    Image image;
118117
M src/main/java/com/scrivenvar/processors/AbstractProcessor.java
4444
4545
  /**
46
   * Constructs a succession without a successor (i.e., next is null).
46
   * Constructs a new default handler with no successor.
4747
   */
4848
  protected AbstractProcessor() {
4949
    this( null );
5050
  }
5151
5252
  /**
5353
   * Constructs a new default handler with a given successor.
5454
   *
55
   * @param successor Use null to indicate last link in the chain.
55
   * @param successor The next processor in the chain.
5656
   */
5757
  public AbstractProcessor( final Processor<T> successor ) {
5858
    mNext = successor;
59
  }
60
61
  @Override
62
  public Processor<T> next() {
63
    return mNext;
5964
  }
6065
6166
  /**
62
   * Processes links in the chain while there are successors and valid data to
63
   * process.
67
   * This algorithm is incorrect, but works for the one use case of removing
68
   * the ending HTML Preview Processor from the end of the processor chain.
69
   * The processor chain is immutable so this creates a succession of
70
   * delegators that wrap each processor in the chain, except for the one
71
   * to be removed.
72
   * <p>
73
   * An alternative is to update the {@link ProcessorFactory} with the ability
74
   * to create a processor chain devoid of an {@link HtmlPreviewProcessor}.
75
   * </p>
6476
   *
65
   * @param t The object to process.
77
   * @param removal The {@link Processor} to remove from the chain.
78
   * @return A delegating processor chain starting from this processor
79
   * onwards with the given processor removed from the chain.
6680
   */
6781
  @Override
68
  public synchronized void processChain( T t ) {
69
    Processor<T> handler = this;
82
  public Processor<T> remove( final Class<? extends Processor<T>> removal ) {
83
    Processor<T> p = this;
84
    final ProcessorDelegator<T> head = new ProcessorDelegator<>( p );
85
    ProcessorDelegator<T> result = head;
7086
71
    while( handler != null && t != null ) {
72
      t = handler.processLink( t );
73
      handler = handler.next();
87
    while( p != null ) {
88
      final Processor<T> next = p.next();
89
90
      if( next != null && next.getClass() != removal ) {
91
        final var delegator = new ProcessorDelegator<>( next );
92
93
        result.setNext( delegator );
94
        result = delegator;
95
      }
96
97
      p = p.next();
7498
    }
99
100
    return head;
75101
  }
76102
77
  @Override
78
  public Processor<T> next() {
79
    return mNext;
103
  private static final class ProcessorDelegator<T>
104
      extends AbstractProcessor<T> {
105
    private final Processor<T> mDelegate;
106
    private Processor<T> mNext;
107
108
    public ProcessorDelegator( final Processor<T> delegate ) {
109
      super( delegate );
110
111
      assert delegate != null;
112
113
      mDelegate = delegate;
114
    }
115
116
    @Override
117
    public T process( T t ) {
118
      return mDelegate.process( t );
119
    }
120
121
    protected void setNext( final Processor<T> next ) {
122
      mNext = next;
123
    }
124
125
    @Override
126
    public Processor<T> next() {
127
      return mNext;
128
    }
80129
  }
81130
}
M src/main/java/com/scrivenvar/processors/DefinitionProcessor.java
5555
   */
5656
  @Override
57
  public String processLink( final String text ) {
57
  public String process( final String text ) {
5858
    return replace( text, getDefinitions() );
5959
  }
D src/main/java/com/scrivenvar/processors/HTMLPreviewProcessor.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import com.scrivenvar.preview.HTMLPreviewPane;
31
32
/**
33
 * Responsible for notifying the HTMLPreviewPane when the succession chain has
34
 * updated. This decouples knowledge of changes to the editor panel from the
35
 * HTML preview panel as well as any processing that takes place before the
36
 * final HTML preview is rendered. This should be the last link in the processor
37
 * chain.
38
 */
39
public class HTMLPreviewProcessor extends AbstractProcessor<String> {
40
41
  // There is only one preview panel.
42
  private static HTMLPreviewPane sHtmlPreviewPane;
43
44
  /**
45
   * Constructs the end of a processing chain.
46
   *
47
   * @param htmlPreviewPane The pane to update with the post-processed document.
48
   */
49
  public HTMLPreviewProcessor( final HTMLPreviewPane htmlPreviewPane ) {
50
    sHtmlPreviewPane = htmlPreviewPane;
51
  }
52
53
  /**
54
   * Update the preview panel using HTML from the succession chain.
55
   *
56
   * @param html The document content to render in the preview pane. The HTML
57
   * should not contain a doctype, head, or body tag, only content to render
58
   * within the body.
59
   *
60
   * @return null
61
   */
62
  @Override
63
  public String processLink( final String html ) {
64
    getHtmlPreviewPane().update( html );
65
66
    // No more processing required.
67
    return null;
68
  }
69
70
  private HTMLPreviewPane getHtmlPreviewPane() {
71
    return sHtmlPreviewPane;
72
  }
73
}
741
A src/main/java/com/scrivenvar/processors/HtmlPreviewProcessor.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import com.scrivenvar.preview.HTMLPreviewPane;
31
32
/**
33
 * Responsible for notifying the HTMLPreviewPane when the succession chain has
34
 * updated. This decouples knowledge of changes to the editor panel from the
35
 * HTML preview panel as well as any processing that takes place before the
36
 * final HTML preview is rendered. This should be the last link in the processor
37
 * chain.
38
 */
39
public class HtmlPreviewProcessor extends AbstractProcessor<String> {
40
41
  // There is only one preview panel.
42
  private static HTMLPreviewPane sHtmlPreviewPane;
43
44
  /**
45
   * Constructs the end of a processing chain.
46
   *
47
   * @param htmlPreviewPane The pane to update with the post-processed document.
48
   */
49
  public HtmlPreviewProcessor( final HTMLPreviewPane htmlPreviewPane ) {
50
    sHtmlPreviewPane = htmlPreviewPane;
51
  }
52
53
  /**
54
   * Update the preview panel using HTML from the succession chain.
55
   *
56
   * @param html The document content to render in the preview pane. The HTML
57
   *             should not contain a doctype, head, or body tag, only
58
   *             content to render within the body.
59
   * @return {@code null} to indicate no more processors in the chain.
60
   */
61
  @Override
62
  public String process( final String html ) {
63
    getHtmlPreviewPane().process( html );
64
65
    // No more processing required.
66
    return null;
67
  }
68
69
  private HTMLPreviewPane getHtmlPreviewPane() {
70
    return sHtmlPreviewPane;
71
  }
72
}
173
M src/main/java/com/scrivenvar/processors/IdentityProcessor.java
5050
   */
5151
  @Override
52
  public String processLink( final String t ) {
52
  public String process( final String t ) {
5353
    return "<pre>" + t + "</pre>";
5454
  }
M src/main/java/com/scrivenvar/processors/InlineRProcessor.java
120120
   */
121121
  @Override
122
  public String processLink( final String text ) {
122
  public String process( final String text ) {
123123
    final int length = text.length();
124124
    final int prefixLength = PREFIX.length();
M src/main/java/com/scrivenvar/processors/Processor.java
3636
3737
  /**
38
   * Provided so that the chain can be invoked from any link using a given
39
   * value. This should be called automatically by a superclass so that
40
   * the links in the chain need only implement the processLink method.
41
   *
42
   * @param t The value to pass along to each link in the chain.
43
   */
44
  void processChain( T t );
45
46
  /**
4738
   * Processes the given content providing a transformation from one document
4839
   * format into another. For example, this could convert from XML to text using
4940
   * an XSLT processor, or from markdown to HTML.
5041
   *
5142
   * @param t The type of object to process.
5243
   * @return The post-processed document, or null if processing should stop.
5344
   */
54
  T processLink( T t );
45
  T process( T t );
46
47
  Processor<T> remove( Class<? extends Processor<T>> processor );
5548
5649
  /**
5750
   * Adds a document processor to call after this processor finishes processing
5851
   * the document given to the process method.
5952
   *
6053
   * @return The processor that should transform the document after this
61
   * instance has finished processing.
54
   * instance has finished processing, or {@code null} if this is the last
55
   * processor in the chain.
6256
   */
63
  Processor<T> next();
57
  default Processor<T> next() {
58
    return null;
59
  }
6460
}
6561
M src/main/java/com/scrivenvar/processors/ProcessorFactory.java
6262
6363
  /**
64
   * Creates a processor suitable for parsing and rendering the file opened at
65
   * the given tab.
64
   * Creates a processor chain suitable for parsing and rendering the file
65
   * opened at the given tab.
6666
   *
6767
   * @param tab The tab containing a text editor, path, and caret position.
6868
   * @return A processor that can render the given tab's text.
6969
   */
70
  public Processor<String> createProcessor( final FileEditorTab tab ) {
70
  public Processor<String> createProcessors( final FileEditorTab tab ) {
7171
    final Path path = tab.getPath();
7272
    final Processor<String> processor;
...
9898
9999
  private Processor<String> createHTMLPreviewProcessor() {
100
    return new HTMLPreviewProcessor( getPreviewPane() );
100
    return new HtmlPreviewProcessor( getPreviewPane() );
101101
  }
102102
...
128128
  protected Processor<String> createXMLProcessor( final FileEditorTab tab ) {
129129
    final var tpc = getCommonProcessor();
130
    final var xmlp = new XMLProcessor( tpc, tab.getPath() );
130
    final var xmlp = new XmlProcessor( tpc, tab.getPath() );
131131
    return createDefinitionProcessor( xmlp );
132132
  }
...
140140
  protected Processor<String> createRXMLProcessor( final FileEditorTab tab ) {
141141
    final var tpc = getCommonProcessor();
142
    final var xmlp = new XMLProcessor( tpc, tab.getPath() );
142
    final var xmlp = new XmlProcessor( tpc, tab.getPath() );
143143
    final var rp = new InlineRProcessor( xmlp, getResolvedMap() );
144144
    return new RVariableProcessor( rp, getResolvedMap() );
D src/main/java/com/scrivenvar/processors/XMLProcessor.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import com.scrivenvar.Services;
31
import com.scrivenvar.service.Snitch;
32
import net.sf.saxon.TransformerFactoryImpl;
33
import net.sf.saxon.trans.XPathException;
34
35
import javax.xml.stream.XMLEventReader;
36
import javax.xml.stream.XMLInputFactory;
37
import javax.xml.stream.XMLStreamException;
38
import javax.xml.stream.events.ProcessingInstruction;
39
import javax.xml.stream.events.XMLEvent;
40
import javax.xml.transform.*;
41
import javax.xml.transform.stream.StreamResult;
42
import javax.xml.transform.stream.StreamSource;
43
import java.io.File;
44
import java.io.Reader;
45
import java.io.StringReader;
46
import java.io.StringWriter;
47
import java.nio.file.Path;
48
import java.nio.file.Paths;
49
50
import static net.sf.saxon.tree.util.ProcInstParser.getPseudoAttribute;
51
52
/**
53
 * Transforms an XML document. The XML document must have a stylesheet specified
54
 * as part of its processing instructions, such as:
55
 *
56
 * <code>xml-stylesheet type="text/xsl" href="markdown.xsl"</code>
57
 * <p>
58
 * The XSL must transform the XML document into Markdown, or another format
59
 * recognized by the next link on the chain.
60
 */
61
public class XMLProcessor extends AbstractProcessor<String>
62
    implements ErrorListener {
63
64
  private final Snitch snitch = Services.load( Snitch.class );
65
66
  private XMLInputFactory xmlInputFactory;
67
  private TransformerFactory transformerFactory;
68
  private Transformer transformer;
69
70
  private Path path;
71
72
  /**
73
   * Constructs an XML processor that can transform an XML document into another
74
   * format based on the XSL file specified as a processing instruction. The
75
   * path must point to the directory where the XSL file is found, which implies
76
   * that they must be in the same directory.
77
   *
78
   * @param processor Next link in the processing chain.
79
   * @param path      The path to the XML file content to be processed.
80
   */
81
  public XMLProcessor( final Processor<String> processor, final Path path ) {
82
    super( processor );
83
    setPath( path );
84
  }
85
86
  /**
87
   * Transforms the given XML text into another form (typically Markdown).
88
   *
89
   * @param text The text to transform, can be empty, cannot be null.
90
   * @return The transformed text, or empty if text is empty.
91
   */
92
  @Override
93
  public String processLink( final String text ) {
94
    try {
95
      return text.isEmpty() ? text : transform( text );
96
    } catch( final Exception ex ) {
97
      throw new RuntimeException( ex );
98
    }
99
  }
100
101
  /**
102
   * Performs an XSL transformation on the given XML text. The XML text must
103
   * have a processing instruction that points to the XSL template file to use
104
   * for the transformation.
105
   *
106
   * @param text The text to transform.
107
   * @return The transformed text.
108
   */
109
  private String transform( final String text ) throws Exception {
110
    // Extract the XML stylesheet processing instruction.
111
    final String template = getXsltFilename( text );
112
    final Path xsl = getXslPath( template );
113
114
    try(
115
        final StringWriter output = new StringWriter( text.length() );
116
        final StringReader input = new StringReader( text ) ) {
117
118
      // Listen for external file modification events.
119
      getSnitch().listen( xsl );
120
121
      getTransformer( xsl ).transform(
122
          new StreamSource( input ),
123
          new StreamResult( output )
124
      );
125
126
      return output.toString();
127
    }
128
  }
129
130
  /**
131
   * Returns an XSL transformer ready to transform an XML document using the
132
   * XSLT file specified by the given path. If the path is already known then
133
   * this will return the associated transformer.
134
   *
135
   * @param xsl The path to an XSLT file.
136
   * @return A transformer that will transform XML documents using the given
137
   * XSLT file.
138
   * @throws TransformerConfigurationException Could not instantiate the
139
   *                                           transformer.
140
   */
141
  private Transformer getTransformer( final Path xsl )
142
      throws TransformerConfigurationException {
143
    if( this.transformer == null ) {
144
      this.transformer = createTransformer( xsl );
145
    }
146
147
    return this.transformer;
148
  }
149
150
  /**
151
   * Creates a configured transformer ready to run.
152
   *
153
   * @param xsl The stylesheet to use for transforming XML documents.
154
   * @return The edited XML document transformed into another format (usually
155
   * markdown).
156
   * @throws TransformerConfigurationException Could not create the transformer.
157
   */
158
  protected Transformer createTransformer( final Path xsl )
159
      throws TransformerConfigurationException {
160
    final Source xslt = new StreamSource( xsl.toFile() );
161
162
    return getTransformerFactory().newTransformer( xslt );
163
  }
164
165
  private Path getXslPath( final String filename ) {
166
    final Path xmlPath = getPath();
167
    final File xmlDirectory = xmlPath.toFile().getParentFile();
168
169
    return Paths.get( xmlDirectory.getPath(), filename );
170
  }
171
172
  /**
173
   * Given XML text, this will use a StAX pull reader to obtain the XML
174
   * stylesheet processing instruction. This will throw a parse exception if the
175
   * href pseudo-attribute filename value cannot be found.
176
   *
177
   * @param xml The XML containing an xml-stylesheet processing instruction.
178
   * @return The href pseudo-attribute value.
179
   * @throws XMLStreamException Could not parse the XML file.
180
   */
181
  private String getXsltFilename( final String xml )
182
      throws XMLStreamException, XPathException {
183
184
    String result = "";
185
186
    try( final StringReader sr = new StringReader( xml ) ) {
187
      boolean found = false;
188
      int count = 0;
189
      final XMLEventReader reader = createXMLEventReader( sr );
190
191
      // If the processing instruction wasn't found in the first 10 lines,
192
      // fail fast. This should iterate twice through the loop.
193
      while( !found && reader.hasNext() && count++ < 10 ) {
194
        final XMLEvent event = reader.nextEvent();
195
196
        if( event.isProcessingInstruction() ) {
197
          final ProcessingInstruction pi = (ProcessingInstruction) event;
198
          final String target = pi.getTarget();
199
200
          if( "xml-stylesheet".equalsIgnoreCase( target ) ) {
201
            result = getPseudoAttribute( pi.getData(), "href" );
202
            found = true;
203
          }
204
        }
205
      }
206
    }
207
208
    return result;
209
  }
210
211
  private XMLEventReader createXMLEventReader( final Reader reader )
212
      throws XMLStreamException {
213
    return getXMLInputFactory().createXMLEventReader( reader );
214
  }
215
216
  private synchronized XMLInputFactory getXMLInputFactory() {
217
    if( this.xmlInputFactory == null ) {
218
      this.xmlInputFactory = createXMLInputFactory();
219
    }
220
221
    return this.xmlInputFactory;
222
  }
223
224
  private XMLInputFactory createXMLInputFactory() {
225
    return XMLInputFactory.newInstance();
226
  }
227
228
  private synchronized TransformerFactory getTransformerFactory() {
229
    if( this.transformerFactory == null ) {
230
      this.transformerFactory = createTransformerFactory();
231
    }
232
233
    return this.transformerFactory;
234
  }
235
236
  /**
237
   * Returns a high-performance XSLT 2 transformation engine.
238
   *
239
   * @return An XSL transforming engine.
240
   */
241
  private TransformerFactory createTransformerFactory() {
242
    final TransformerFactory factory = new TransformerFactoryImpl();
243
244
    // Bubble problems up to the user interface, rather than standard error.
245
    factory.setErrorListener( this );
246
247
    return factory;
248
  }
249
250
  /**
251
   * Called when the XSL transformer issues a warning.
252
   *
253
   * @param ex The problem the transformer encountered.
254
   */
255
  @Override
256
  public void warning( final TransformerException ex ) {
257
    throw new RuntimeException( ex );
258
  }
259
260
  /**
261
   * Called when the XSL transformer issues an error.
262
   *
263
   * @param ex The problem the transformer encountered.
264
   */
265
  @Override
266
  public void error( final TransformerException ex ) {
267
    throw new RuntimeException( ex );
268
  }
269
270
  /**
271
   * Called when the XSL transformer issues a fatal error, which is probably
272
   * a bit over-dramatic a method name.
273
   *
274
   * @param ex The problem the transformer encountered.
275
   */
276
  @Override
277
  public void fatalError( final TransformerException ex ) {
278
    throw new RuntimeException( ex );
279
  }
280
281
  private void setPath( final Path path ) {
282
    this.path = path;
283
  }
284
285
  private Path getPath() {
286
    return this.path;
287
  }
288
289
  private Snitch getSnitch() {
290
    return this.snitch;
291
  }
292
}
2931
A src/main/java/com/scrivenvar/processors/XmlProcessor.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import com.scrivenvar.Services;
31
import com.scrivenvar.service.Snitch;
32
import net.sf.saxon.TransformerFactoryImpl;
33
import net.sf.saxon.trans.XPathException;
34
35
import javax.xml.stream.XMLEventReader;
36
import javax.xml.stream.XMLInputFactory;
37
import javax.xml.stream.XMLStreamException;
38
import javax.xml.stream.events.ProcessingInstruction;
39
import javax.xml.stream.events.XMLEvent;
40
import javax.xml.transform.*;
41
import javax.xml.transform.stream.StreamResult;
42
import javax.xml.transform.stream.StreamSource;
43
import java.io.File;
44
import java.io.Reader;
45
import java.io.StringReader;
46
import java.io.StringWriter;
47
import java.nio.file.Path;
48
import java.nio.file.Paths;
49
50
import static net.sf.saxon.tree.util.ProcInstParser.getPseudoAttribute;
51
52
/**
53
 * Transforms an XML document. The XML document must have a stylesheet specified
54
 * as part of its processing instructions, such as:
55
 * <p>
56
 * {@code xml-stylesheet type="text/xsl" href="markdown.xsl"}
57
 * </p>
58
 * <p>
59
 * The XSL must transform the XML document into Markdown, or another format
60
 * recognized by the next link on the chain.
61
 * </p>
62
 */
63
public class XmlProcessor extends AbstractProcessor<String>
64
    implements ErrorListener {
65
66
  private final Snitch snitch = Services.load( Snitch.class );
67
68
  private XMLInputFactory xmlInputFactory;
69
  private TransformerFactory transformerFactory;
70
  private Transformer transformer;
71
72
  private Path path;
73
74
  /**
75
   * Constructs an XML processor that can transform an XML document into another
76
   * format based on the XSL file specified as a processing instruction. The
77
   * path must point to the directory where the XSL file is found, which implies
78
   * that they must be in the same directory.
79
   *
80
   * @param processor Next link in the processing chain.
81
   * @param path      The path to the XML file content to be processed.
82
   */
83
  public XmlProcessor( final Processor<String> processor, final Path path ) {
84
    super( processor );
85
    setPath( path );
86
  }
87
88
  /**
89
   * Transforms the given XML text into another form (typically Markdown).
90
   *
91
   * @param text The text to transform, can be empty, cannot be null.
92
   * @return The transformed text, or empty if text is empty.
93
   */
94
  @Override
95
  public String process( final String text ) {
96
    try {
97
      return text.isEmpty() ? text : transform( text );
98
    } catch( final Exception ex ) {
99
      throw new RuntimeException( ex );
100
    }
101
  }
102
103
  /**
104
   * Performs an XSL transformation on the given XML text. The XML text must
105
   * have a processing instruction that points to the XSL template file to use
106
   * for the transformation.
107
   *
108
   * @param text The text to transform.
109
   * @return The transformed text.
110
   */
111
  private String transform( final String text ) throws Exception {
112
    // Extract the XML stylesheet processing instruction.
113
    final String template = getXsltFilename( text );
114
    final Path xsl = getXslPath( template );
115
116
    try(
117
        final StringWriter output = new StringWriter( text.length() );
118
        final StringReader input = new StringReader( text ) ) {
119
120
      // Listen for external file modification events.
121
      getSnitch().listen( xsl );
122
123
      getTransformer( xsl ).transform(
124
          new StreamSource( input ),
125
          new StreamResult( output )
126
      );
127
128
      return output.toString();
129
    }
130
  }
131
132
  /**
133
   * Returns an XSL transformer ready to transform an XML document using the
134
   * XSLT file specified by the given path. If the path is already known then
135
   * this will return the associated transformer.
136
   *
137
   * @param xsl The path to an XSLT file.
138
   * @return A transformer that will transform XML documents using the given
139
   * XSLT file.
140
   * @throws TransformerConfigurationException Could not instantiate the
141
   *                                           transformer.
142
   */
143
  private Transformer getTransformer( final Path xsl )
144
      throws TransformerConfigurationException {
145
    if( this.transformer == null ) {
146
      this.transformer = createTransformer( xsl );
147
    }
148
149
    return this.transformer;
150
  }
151
152
  /**
153
   * Creates a configured transformer ready to run.
154
   *
155
   * @param xsl The stylesheet to use for transforming XML documents.
156
   * @return The edited XML document transformed into another format (usually
157
   * markdown).
158
   * @throws TransformerConfigurationException Could not create the transformer.
159
   */
160
  protected Transformer createTransformer( final Path xsl )
161
      throws TransformerConfigurationException {
162
    final Source xslt = new StreamSource( xsl.toFile() );
163
164
    return getTransformerFactory().newTransformer( xslt );
165
  }
166
167
  private Path getXslPath( final String filename ) {
168
    final Path xmlPath = getPath();
169
    final File xmlDirectory = xmlPath.toFile().getParentFile();
170
171
    return Paths.get( xmlDirectory.getPath(), filename );
172
  }
173
174
  /**
175
   * Given XML text, this will use a StAX pull reader to obtain the XML
176
   * stylesheet processing instruction. This will throw a parse exception if the
177
   * href pseudo-attribute filename value cannot be found.
178
   *
179
   * @param xml The XML containing an xml-stylesheet processing instruction.
180
   * @return The href pseudo-attribute value.
181
   * @throws XMLStreamException Could not parse the XML file.
182
   */
183
  private String getXsltFilename( final String xml )
184
      throws XMLStreamException, XPathException {
185
186
    String result = "";
187
188
    try( final StringReader sr = new StringReader( xml ) ) {
189
      boolean found = false;
190
      int count = 0;
191
      final XMLEventReader reader = createXMLEventReader( sr );
192
193
      // If the processing instruction wasn't found in the first 10 lines,
194
      // fail fast. This should iterate twice through the loop.
195
      while( !found && reader.hasNext() && count++ < 10 ) {
196
        final XMLEvent event = reader.nextEvent();
197
198
        if( event.isProcessingInstruction() ) {
199
          final ProcessingInstruction pi = (ProcessingInstruction) event;
200
          final String target = pi.getTarget();
201
202
          if( "xml-stylesheet".equalsIgnoreCase( target ) ) {
203
            result = getPseudoAttribute( pi.getData(), "href" );
204
            found = true;
205
          }
206
        }
207
      }
208
    }
209
210
    return result;
211
  }
212
213
  private XMLEventReader createXMLEventReader( final Reader reader )
214
      throws XMLStreamException {
215
    return getXMLInputFactory().createXMLEventReader( reader );
216
  }
217
218
  private synchronized XMLInputFactory getXMLInputFactory() {
219
    if( this.xmlInputFactory == null ) {
220
      this.xmlInputFactory = createXMLInputFactory();
221
    }
222
223
    return this.xmlInputFactory;
224
  }
225
226
  private XMLInputFactory createXMLInputFactory() {
227
    return XMLInputFactory.newInstance();
228
  }
229
230
  private synchronized TransformerFactory getTransformerFactory() {
231
    if( this.transformerFactory == null ) {
232
      this.transformerFactory = createTransformerFactory();
233
    }
234
235
    return this.transformerFactory;
236
  }
237
238
  /**
239
   * Returns a high-performance XSLT 2 transformation engine.
240
   *
241
   * @return An XSL transforming engine.
242
   */
243
  private TransformerFactory createTransformerFactory() {
244
    final TransformerFactory factory = new TransformerFactoryImpl();
245
246
    // Bubble problems up to the user interface, rather than standard error.
247
    factory.setErrorListener( this );
248
249
    return factory;
250
  }
251
252
  /**
253
   * Called when the XSL transformer issues a warning.
254
   *
255
   * @param ex The problem the transformer encountered.
256
   */
257
  @Override
258
  public void warning( final TransformerException ex ) {
259
    throw new RuntimeException( ex );
260
  }
261
262
  /**
263
   * Called when the XSL transformer issues an error.
264
   *
265
   * @param ex The problem the transformer encountered.
266
   */
267
  @Override
268
  public void error( final TransformerException ex ) {
269
    throw new RuntimeException( ex );
270
  }
271
272
  /**
273
   * Called when the XSL transformer issues a fatal error, which is probably
274
   * a bit over-dramatic a method name.
275
   *
276
   * @param ex The problem the transformer encountered.
277
   */
278
  @Override
279
  public void fatalError( final TransformerException ex ) {
280
    throw new RuntimeException( ex );
281
  }
282
283
  private void setPath( final Path path ) {
284
    this.path = path;
285
  }
286
287
  private Path getPath() {
288
    return this.path;
289
  }
290
291
  private Snitch getSnitch() {
292
    return this.snitch;
293
  }
294
}
1295
M src/main/java/com/scrivenvar/processors/markdown/BlockExtension.java
22
33
import com.vladsch.flexmark.ast.BlockQuote;
4
import com.vladsch.flexmark.ast.ListBlock;
45
import com.vladsch.flexmark.html.AttributeProvider;
56
import com.vladsch.flexmark.html.AttributeProviderFactory;
...
1516
1617
import static com.scrivenvar.Constants.PARAGRAPH_ID_PREFIX;
18
import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT;
1719
1820
/**
...
4749
      // line in the resulting document; however, a > symbol in the text editor
4850
      // does not count as a blank line. Resolving this issue is tricky.
49
      if( node instanceof Block && !(node instanceof BlockQuote) ) {
51
      //
52
      // The CODE_CONTENT represents <code> embedded inside <pre>; both elements
53
      // enter this method as FencedCodeBlock, but only the <pre> must be
54
      // uniquely identified (because they are the same line in Markdown).
55
      //
56
      if( node instanceof Block &&
57
          !(node instanceof BlockQuote) &&
58
          !(node instanceof ListBlock) &&
59
          (part != CODE_CONTENT) ) {
5060
        attributes.addValue( "id", PARAGRAPH_ID_PREFIX + mCount++ );
5161
      }
M src/main/java/com/scrivenvar/processors/markdown/MarkdownProcessor.java
9393
   */
9494
  @Override
95
  public String processLink( final String markdown ) {
95
  public String process( final String markdown ) {
9696
    return toHtml( markdown );
9797
  }
M src/main/resources/com/scrivenvar/messages.properties
1919
2020
Main.menu.edit=_Edit
21
Main.menu.edit.copy.html=Copy _HTML
2122
Main.menu.edit.undo=_Undo
2223
Main.menu.edit.redo=_Redo
24
Main.menu.edit.cut=Cu_t
25
Main.menu.edit.copy=_Copy
26
Main.menu.edit.paste=_Paste
2327
Main.menu.edit.find=_Find
2428
Main.menu.edit.find.next=Find _Next
M src/main/resources/com/scrivenvar/preview/webview.css
66
  /* Must be bundled in JAR file. */
77
  font-family: "Vollkorn", serif;
8
  font-size: 12pt;
98
  background-color: #fff;
109
  margin: 0 auto;
...
132131
  font-family: "Fira Code", monospace;
133132
  font-size: 10pt;
133
  background-color: #f8f8f8;
134
  text-decoration: none;
134135
  white-space: pre-wrap;
135136
  word-wrap: break-word;
136137
  overflow-wrap: anywhere;
138
  border-radius: .125em;
137139
}
138140
139141
code, tt {
140
  background-color: #f8f8f8;
141
  min-width: 1em;
142
  padding: .2em .3em;
143
  text-align: center;
144
  text-decoration: none;
145
  border-radius: .3em;
146
  border: none;
142
  padding: .25em;
147143
}
148144
149
pre>code {
145
pre > code {
146
  /* Reset the padding. */
147
  padding: 0;
150148
  border: none;
151149
  background: transparent;
152150
}
153151
154152
pre {
155
  background-color: #f8f8f8;
156153
  border: .125em solid #ccc;
154
  overflow: auto;
155
  /* Assign the new padding, independently from previous. */
157156
  padding: .25em .5em;
158
  border-radius: .25em;
159157
}
160158