Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M BUILD.md
77
Download and install the following software packages:
88
9
* [JDK 16](https://bell-sw.com/pages/downloads/?version=java-16) (Full JDK + JavaFX)
10
* [Gradle 7.0](https://services.gradle.org/distributions)
11
* [Git 2.28.0](https://git-scm.com/downloads)
9
* [JDK 17](https://bell-sw.com/pages/downloads/?version=java-17) (Full JDK + JavaFX)
10
* [Gradle 7.2](https://gradle.org/releases)
11
* [Git 2.33](https://git-scm.com/downloads)
1212
1313
## Repository
...
2929
3030
# Run
31
32
After the application is compiled, run it as follows:
33
34
    java --illegal-access=permit -jar build/libs/keenwrite.jar
35
36
On Windows:
3731
38
    java --illegal-access=permit -jar build\libs\keenwrite.jar
32
After the application is compiled, run it using `keenwrite.sh`.
3933
4034
# Integrated development environments
...
5852
5953
The project is imported into the IDE.
60
61
### Configure
62
63
Configure the IDE to run the application as follows:
64
65
1. Click **Run → Edit Configurations**.
66
1. Click **+** to add a new configuration.
67
1. Set **Name** to: KeenWrite
68
1. Click **Modify Options → Add VM options**.
69
1. Set **VM options** field to: `--illegal-access=permit`
70
1. Click **OK** close the dialog.
7154
72
The changes should resemble:
55
### Run
7356
74
![Run Configuration](docs/images/app-ide.png)
57
Run the application within the IDE as follows:
7558
76
### Run
59
1. Open **Launcher.java**.
60
1. Click **Run → Launcher**.
7761
78
Click **Run → KeenWrite** to launch the application.
62
The application is started.
7963
8064
# Installers
M README.md
3636
### Other
3737
38
Download and install a full version of [JRE 16](https://bell-sw.com/pages/downloads/?version=java-16&package=jre-full) that includes JavaFX module support, then run:
38
On other platforms, start the application as follows:
3939
40
``` bash
41
java --illegal-access=permit -jar build/libs/keenwrite.jar 2> /dev/null
42
```
40
1. Download the *full version* of the Java Runtime Environment, [JRE 17](https://bell-sw.com/pages/downloads/?version=java-17).
41
1. Install the JRE.
42
1. Open a terminal window.
43
1. Verify the installation: `java -version`
44
1. Make `keenwrite.sh` executable.
45
1. Run: `./keenwrite.sh`
4346
44
The `--illegal-access=permit` is a temporary option until third-party libraries used by the text editor are updated or replaced.
47
The application is started.
4548
4649
## Features
M build.gradle
77
repositories {
88
  mavenCentral()
9
  jcenter()
109
1110
  maven {
...
3837
    "--add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
3938
    "--add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED",
40
    "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED",
4139
    "--add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED",
40
    "--add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED",
41
    "--add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED",
42
    "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED",
4243
]
4344
4445
javafx {
45
  version = "16"
46
  version = "17"
4647
  modules = ['javafx.controls', 'javafx.swing']
4748
  configuration = 'compileOnly'
4849
}
4950
5051
dependencies {
51
  def v_junit = '5.7.2'
52
  def v_junit = '5.8.1'
5253
  def v_flexmark = '0.62.2'
53
  def v_jackson = '2.12.5'
54
  def v_jackson = '2.13.0'
5455
  def v_batik = '1.14'
5556
  def v_wheatsheaf = '2.0.1'
5657
5758
  // JavaFX
58
  implementation 'org.controlsfx:controlsfx:11.1.0'
59
  implementation 'org.fxmisc.richtext:richtextfx:0.10.6'
59
  // TODO: Reinstate when JDK 17-compatible release is published
60
  //implementation 'org.controlsfx:controlsfx:11.1.0'
61
  implementation 'org.fxmisc.richtext:richtextfx:0.10.7'
62
  implementation 'org.fxmisc.flowless:flowless:0.6.7'
6063
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
6164
  implementation 'com.miglayout:miglayout-javafx:11.0'
...
8689
8790
  // HTML parsing and rendering
88
  implementation 'org.jsoup:jsoup:1.14.2'
89
  implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22'
91
  implementation 'org.jsoup:jsoup:1.14.3'
92
  // TODO: Wait for https://github.com/flyingsaucerproject/flyingsaucer/pull/170
93
  //implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22'
9094
9195
  // R
...
116120
  implementation 'org.greenrobot:eventbus:3.2.0'
117121
118
  // TODO: Update Workspace config to use Jackson to shave ~800kb
119122
  implementation 'org.apache.commons:commons-configuration2:2.7'
123
  //noinspection GradlePackageUpdate
120124
  implementation 'commons-beanutils:commons-beanutils:1.9.4'
121125
...
131135
  }
132136
137
  testImplementation "org.testfx:testfx-junit5:4.0.16-alpha"
133138
  testImplementation "org.junit.jupiter:junit-jupiter-api:${v_junit}"
134139
  testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
135
136
  testImplementation "org.testfx:testfx-junit5:4.0.16-alpha"
137140
}
138141
M installer.sh
1212
readonly APP_NAME=$(find "${SCRIPT_DIR}/src" -type f -name "settings.properties" -exec cat {} \; | grep "application.title=" | cut -d'=' -f2)
1313
readonly FILE_APP_JAR="${APP_NAME}.jar"
14
15
# JDK 16 work-around until RichTextFX is fixed.
16
# See: https://github.com/FXMisc/RichTextFX/issues/1013
17
readonly OPT_JAVA="--illegal-access=permit"
14
readonly OPT_JAVA=$(cat << END_OF_ARGS
15
--add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \
16
--add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED \
17
--add-opens=javafx.graphics/javafx.scene.text=ALL-UNNAMED \
18
--add-opens=javafx.graphics/com.sun.javafx.css=ALL-UNNAMED \
19
--add-opens=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \
20
--add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED \
21
--add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED \
22
--add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED \
23
--add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \
24
--add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED \
25
--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED
26
END_OF_ARGS
27
)
1828
1929
ARG_JAVA_OS="linux"
2030
ARG_JAVA_ARCH="amd64"
21
ARG_JAVA_VERSION="16.0.1"
22
ARG_JAVA_UPDATE="9"
31
ARG_JAVA_VERSION="17"
32
ARG_JAVA_UPDATE="35"
2333
ARG_JAVA_DIR="java"
2434
M keenwrite.sh
11
#!/usr/bin/env bash
22
3
java --illegal-access=permit -jar build/libs/keenwrite.jar 2> /dev/null
3
java \
4
  --add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \
5
  --add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED \
6
  --add-opens=javafx.graphics/javafx.scene.text=ALL-UNNAMED \
7
  --add-opens=javafx.graphics/com.sun.javafx.css=ALL-UNNAMED \
8
  --add-opens=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \
9
  --add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED \
10
  --add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED \
11
  --add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED \
12
  --add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \
13
  --add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED \
14
  --add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED \
15
  --add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED \
16
  -jar build/libs/keenwrite.jar
417
518
A libs/controlsfx-11.1.1.jar
Binary file
A libs/flying-saucer-core-9.1.22.jar
Binary file
M src/main/java/com/keenwrite/MainApp.java
44
import com.keenwrite.events.HyperlinkOpenEvent;
55
import com.keenwrite.preferences.Workspace;
6
import com.keenwrite.util.ArrayScanner;
67
import javafx.application.Application;
78
import javafx.event.Event;
...
4142
   */
4243
  public static void main( final String[] args ) {
43
    disableLogging();
44
    if( !ArrayScanner.contains( args, "--debug" ) ) {
45
      disableLogging();
46
    }
47
4448
    launch( args );
4549
  }
M src/main/java/com/keenwrite/MainPane.java
1313
import com.keenwrite.preferences.Key;
1414
import com.keenwrite.preferences.Workspace;
15
import com.keenwrite.preview.HtmlPanel;
16
import com.keenwrite.preview.HtmlPreview;
17
import com.keenwrite.processors.Processor;
18
import com.keenwrite.processors.ProcessorContext;
19
import com.keenwrite.processors.ProcessorFactory;
20
import com.keenwrite.processors.markdown.extensions.CaretExtension;
21
import com.keenwrite.service.events.Notifier;
22
import com.keenwrite.sigils.RSigilOperator;
23
import com.keenwrite.sigils.SigilOperator;
24
import com.keenwrite.sigils.Tokens;
25
import com.keenwrite.sigils.YamlSigilOperator;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.heuristics.DocumentStatistics;
28
import com.keenwrite.ui.outline.DocumentOutline;
29
import com.panemu.tiwulfx.control.dock.DetachableTab;
30
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
31
import javafx.application.Platform;
32
import javafx.beans.property.*;
33
import javafx.collections.ListChangeListener;
34
import javafx.concurrent.Task;
35
import javafx.event.ActionEvent;
36
import javafx.event.Event;
37
import javafx.event.EventHandler;
38
import javafx.scene.Node;
39
import javafx.scene.Scene;
40
import javafx.scene.control.*;
41
import javafx.scene.control.TreeItem.TreeModificationEvent;
42
import javafx.scene.input.KeyEvent;
43
import javafx.scene.layout.FlowPane;
44
import javafx.stage.Stage;
45
import javafx.stage.Window;
46
import org.greenrobot.eventbus.Subscribe;
47
48
import java.io.File;
49
import java.io.FileNotFoundException;
50
import java.nio.file.Path;
51
import java.util.*;
52
import java.util.concurrent.ExecutorService;
53
import java.util.concurrent.ScheduledExecutorService;
54
import java.util.concurrent.ScheduledFuture;
55
import java.util.concurrent.atomic.AtomicBoolean;
56
import java.util.concurrent.atomic.AtomicReference;
57
import java.util.function.Function;
58
import java.util.stream.Collectors;
59
60
import static com.keenwrite.ExportFormat.NONE;
61
import static com.keenwrite.Messages.get;
62
import static com.keenwrite.constants.Constants.*;
63
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
64
import static com.keenwrite.events.Bus.register;
65
import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
66
import static com.keenwrite.events.StatusEvent.clue;
67
import static com.keenwrite.io.MediaType.*;
68
import static com.keenwrite.preferences.WorkspaceKeys.*;
69
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
70
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
71
import static java.lang.String.format;
72
import static java.lang.System.getProperty;
73
import static java.util.concurrent.Executors.newFixedThreadPool;
74
import static java.util.concurrent.Executors.newScheduledThreadPool;
75
import static java.util.concurrent.TimeUnit.SECONDS;
76
import static java.util.stream.Collectors.groupingBy;
77
import static javafx.application.Platform.runLater;
78
import static javafx.scene.control.Alert.AlertType.ERROR;
79
import static javafx.scene.control.ButtonType.*;
80
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
81
import static javafx.scene.input.KeyCode.SPACE;
82
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
83
import static javafx.util.Duration.millis;
84
import static javax.swing.SwingUtilities.invokeLater;
85
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
86
87
/**
88
 * Responsible for wiring together the main application components for a
89
 * particular workspace (project). These include the definition views,
90
 * text editors, and preview pane along with any corresponding controllers.
91
 */
92
public final class MainPane extends SplitPane {
93
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
94
95
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
96
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
97
    new AtomicReference<>();
98
99
  private static final Notifier sNotifier = Services.load( Notifier.class );
100
101
  /**
102
   * Used when opening files to determine how each file should be binned and
103
   * therefore what tab pane to be opened within.
104
   */
105
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
106
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
107
  );
108
109
  /**
110
   * Prevents re-instantiation of processing classes.
111
   */
112
  private final Map<TextResource, Processor<String>> mProcessors =
113
    new HashMap<>();
114
115
  private final Workspace mWorkspace;
116
117
  /**
118
   * Groups similar file type tabs together.
119
   */
120
  private final List<TabPane> mTabPanes = new ArrayList<>();
121
122
  /**
123
   * Stores definition names and values.
124
   */
125
  private final Map<String, String> mResolvedMap =
126
    new HashMap<>( MAP_SIZE_DEFAULT );
127
128
  /**
129
   * Renders the actively selected plain text editor tab.
130
   */
131
  private final HtmlPreview mPreview;
132
133
  /**
134
   * Provides an interactive document outline.
135
   */
136
  private final DocumentOutline mOutline = new DocumentOutline();
137
138
  /**
139
   * Changing the active editor fires the value changed event. This allows
140
   * refreshes to happen when external definitions are modified and need to
141
   * trigger the processing chain.
142
   */
143
  private final ObjectProperty<TextEditor> mActiveTextEditor =
144
    createActiveTextEditor();
145
146
  /**
147
   * Changing the active definition editor fires the value changed event. This
148
   * allows refreshes to happen when external definitions are modified and need
149
   * to trigger the processing chain.
150
   */
151
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
152
    createActiveDefinitionEditor( mActiveTextEditor );
153
154
  /**
155
   * Tracks the number of detached tab panels opened into their own windows,
156
   * which allows unique identification of subordinate windows by their title.
157
   * It is doubtful more than 128 windows, much less 256, will be created.
158
   */
159
  private byte mWindowCount;
160
161
  /**
162
   * Called when the definition data is changed.
163
   */
164
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
165
    event -> {
166
      final var editor = mActiveDefinitionEditor.get();
167
168
      resolve( editor );
169
      process( getActiveTextEditor() );
170
      save( editor );
171
    };
172
173
  private final DocumentStatistics mStatistics;
174
175
  /**
176
   * Adds all content panels to the main user interface. This will load the
177
   * configuration settings from the workspace to reproduce the settings from
178
   * a previous session.
179
   */
180
  public MainPane( final Workspace workspace ) {
181
    mWorkspace = workspace;
182
    mPreview = new HtmlPreview( workspace );
183
    mStatistics = new DocumentStatistics( workspace );
184
    mActiveTextEditor.set( new MarkdownEditor( workspace ) );
185
186
    open( bin( getRecentFiles() ) );
187
    viewPreview();
188
    setDividerPositions( calculateDividerPositions() );
189
190
    // Once the main scene's window regains focus, update the active definition
191
    // editor to the currently selected tab.
192
    runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
193
      // Order matters here. We want to close all the tabs to ensure each
194
      // is saved, but after they are closed, the workspace should still
195
      // retain the list of files that were open. If this line came after
196
      // closing, then restarting the application would list no files.
197
      mWorkspace.save();
198
199
      if( closeAll() ) {
200
        Platform.exit();
201
        System.exit( 0 );
202
      }
203
      else {
204
        event.consume();
205
      }
206
    } ) );
207
208
    register( this );
209
    initAutosave( workspace );
210
  }
211
212
  @Subscribe
213
  public void handle( final TextEditorFocusEvent event ) {
214
    mActiveTextEditor.set( event.get() );
215
  }
216
217
  @Subscribe
218
  public void handle( final TextDefinitionFocusEvent event ) {
219
    mActiveDefinitionEditor.set( event.get() );
220
  }
221
222
  /**
223
   * Typically called when a file name is clicked in the {@link HtmlPanel}.
224
   *
225
   * @param event The event to process, must contain a valid file reference.
226
   */
227
  @Subscribe
228
  public void handle( final FileOpenEvent event ) {
229
    final File eventFile;
230
    final var eventUri = event.getUri();
231
232
    if( eventUri.isAbsolute() ) {
233
      eventFile = new File( eventUri.getPath() );
234
    }
235
    else {
236
      final var activeFile = getActiveTextEditor().getFile();
237
      final var parent = activeFile.getParentFile();
238
239
      if( parent == null ) {
240
        clue( new FileNotFoundException( eventUri.getPath() ) );
241
        return;
242
      }
243
      else {
244
        final var parentPath = parent.getAbsolutePath();
245
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
246
      }
247
    }
248
249
    runLater( () -> open( eventFile ) );
250
  }
251
252
  @Subscribe
253
  public void handle( final CaretNavigationEvent event ) {
254
    runLater( () -> {
255
      final var textArea = getActiveTextEditor().getTextArea();
256
      textArea.moveTo( event.getOffset() );
257
      textArea.requestFollowCaret();
258
      textArea.requestFocus();
259
    } );
260
  }
261
262
  @Subscribe
263
  @SuppressWarnings( "unused" )
264
  public void handle( final ExportFailedEvent event ) {
265
    final var os = getProperty( "os.name" );
266
    final var arch = getProperty( "os.arch" ).toLowerCase();
267
    final var bits = getProperty( "sun.arch.data.model" );
268
269
    final var title = Messages.get( "Alert.typesetter.missing.title" );
270
    final var header = Messages.get( "Alert.typesetter.missing.header" );
271
    final var version = Messages.get(
272
      "Alert.typesetter.missing.version",
273
      os,
274
      arch
275
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
276
        .replaceAll( "mips.*", "MIPS" )
277
        .replaceAll( "armv.*", "ARM" ),
278
      bits );
279
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
280
281
    // Download and install ConTeXt for {0} {1} {2}-bit
282
    final var content = format( "%s %s", text, version );
283
    final var flowPane = new FlowPane();
284
    final var link = new Hyperlink( text );
285
    final var label = new Label( version );
286
    flowPane.getChildren().addAll( link, label );
287
288
    final var alert = new Alert( ERROR, content, OK );
289
    alert.setTitle( title );
290
    alert.setHeaderText( header );
291
    alert.getDialogPane().contentProperty().set( flowPane );
292
    alert.setGraphic( ICON_DIALOG_NODE );
293
294
    link.setOnAction( ( e ) -> {
295
      alert.close();
296
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
297
      runLater( () -> fireHyperlinkOpenEvent( url ) );
298
    } );
299
300
    alert.showAndWait();
301
  }
302
303
  private void initAutosave( final Workspace workspace ) {
304
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
305
306
    rate.addListener(
307
      ( c, o, n ) -> {
308
        final var taskRef = mSaveTask.get();
309
310
        // Prevent multiple autosaves from running.
311
        if( taskRef != null ) {
312
          taskRef.cancel( false );
313
        }
314
315
        initAutosave( rate );
316
      }
317
    );
318
319
    // Start the save listener (avoids duplicating some code).
320
    initAutosave( rate );
321
  }
322
323
  private void initAutosave( final IntegerProperty rate ) {
324
    mSaveTask.set(
325
      mSaver.scheduleAtFixedRate(
326
        () -> {
327
          if( getActiveTextEditor().isModified() ) {
328
            // Ensure the modified indicator is cleared by running on EDT.
329
            runLater( this::save );
330
          }
331
        }, 0, rate.intValue(), SECONDS
332
      )
333
    );
334
  }
335
336
  /**
337
   * TODO: Load divider positions from exported settings, see bin() comment.
338
   */
339
  private double[] calculateDividerPositions() {
340
    final var ratio = 100f / getItems().size() / 100;
341
    final var positions = getDividerPositions();
342
343
    for( int i = 0; i < positions.length; i++ ) {
344
      positions[ i ] = ratio * i;
345
    }
346
347
    return positions;
348
  }
349
350
  /**
351
   * Opens all the files into the application, provided the paths are unique.
352
   * This may only be called for any type of files that a user can edit
353
   * (i.e., update and persist), such as definitions and text files.
354
   *
355
   * @param files The list of files to open.
356
   */
357
  public void open( final List<File> files ) {
358
    files.forEach( this::open );
359
  }
360
361
  /**
362
   * This opens the given file. Since the preview pane is not a file that
363
   * can be opened, it is safe to add a listener to the detachable pane.
364
   *
365
   * @param file The file to open.
366
   */
367
  private void open( final File file ) {
368
    final var tab = createTab( file );
369
    final var node = tab.getContent();
370
    final var mediaType = MediaType.valueFrom( file );
371
    final var tabPane = obtainTabPane( mediaType );
372
373
    tab.setTooltip( createTooltip( file ) );
374
    tabPane.setFocusTraversable( false );
375
    tabPane.setTabClosingPolicy( ALL_TABS );
376
    tabPane.getTabs().add( tab );
377
378
    // Attach the tab scene factory for new tab panes.
379
    if( !getItems().contains( tabPane ) ) {
380
      addTabPane(
381
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
382
      );
383
    }
384
385
    getRecentFiles().add( file.getAbsolutePath() );
386
  }
387
388
  /**
389
   * Opens a new text editor document using the default document file name.
390
   */
391
  public void newTextEditor() {
392
    open( DOCUMENT_DEFAULT );
393
  }
394
395
  /**
396
   * Opens a new definition editor document using the default definition
397
   * file name.
398
   */
399
  public void newDefinitionEditor() {
400
    open( DEFINITION_DEFAULT );
401
  }
402
403
  /**
404
   * Iterates over all tab panes to find all {@link TextEditor}s and request
405
   * that they save themselves.
406
   */
407
  public void saveAll() {
408
    mTabPanes.forEach(
409
      ( tp ) -> tp.getTabs().forEach( ( tab ) -> {
410
        final var node = tab.getContent();
411
        if( node instanceof final TextEditor editor ) {
412
          save( editor );
413
        }
414
      } )
415
    );
416
  }
417
418
  /**
419
   * Requests that the active {@link TextEditor} saves itself. Don't bother
420
   * checking if modified first because if the user swaps external media from
421
   * an external source (e.g., USB thumb drive), save should not second-guess
422
   * the user: save always re-saves. Also, it's less code.
423
   */
424
  public void save() {
425
    save( getActiveTextEditor() );
426
  }
427
428
  /**
429
   * Saves the active {@link TextEditor} under a new name.
430
   *
431
   * @param files The new active editor {@link File} reference, must contain
432
   *              at least one element.
433
   */
434
  public void saveAs( final List<File> files ) {
435
    assert files != null;
436
    assert !files.isEmpty();
437
    final var editor = getActiveTextEditor();
438
    final var tab = getTab( editor );
439
    final var file = files.get( 0 );
440
441
    editor.rename( file );
442
    tab.ifPresent( t -> {
443
      t.setText( editor.getFilename() );
444
      t.setTooltip( createTooltip( file ) );
445
    } );
446
447
    save();
448
  }
449
450
  /**
451
   * Saves the given {@link TextResource} to a file. This is typically used
452
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
453
   *
454
   * @param resource The resource to export.
455
   */
456
  private void save( final TextResource resource ) {
457
    try {
458
      resource.save();
459
    } catch( final Exception ex ) {
460
      clue( ex );
461
      sNotifier.alert(
462
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
463
      );
464
    }
465
  }
466
467
  /**
468
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
469
   *
470
   * @return {@code true} when all editors, modified or otherwise, were
471
   * permitted to close; {@code false} when one or more editors were modified
472
   * and the user requested no closing.
473
   */
474
  public boolean closeAll() {
475
    var closable = true;
476
477
    for( final var tabPane : mTabPanes ) {
478
      final var tabIterator = tabPane.getTabs().iterator();
479
480
      while( tabIterator.hasNext() ) {
481
        final var tab = tabIterator.next();
482
        final var resource = tab.getContent();
483
484
        // The definition panes auto-save, so being specific here prevents
485
        // closing the definitions in the situation where the user wants to
486
        // continue editing (i.e., possibly save unsaved work).
487
        if( !(resource instanceof TextEditor) ) {
488
          continue;
489
        }
490
491
        if( canClose( (TextEditor) resource ) ) {
492
          tabIterator.remove();
493
          close( tab );
494
        }
495
        else {
496
          closable = false;
497
        }
498
      }
499
    }
500
501
    return closable;
502
  }
503
504
  /**
505
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
506
   * event.
507
   *
508
   * @param tab The {@link Tab} that was closed.
509
   */
510
  private void close( final Tab tab ) {
511
    assert tab != null;
512
513
    final var handler = tab.getOnClosed();
514
515
    if( handler != null ) {
516
      handler.handle( new ActionEvent() );
517
    }
518
  }
519
520
  /**
521
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
522
   */
523
  public void close() {
524
    final var editor = getActiveTextEditor();
525
526
    if( canClose( editor ) ) {
527
      close( editor );
528
    }
529
  }
530
531
  /**
532
   * Closes the given {@link TextResource}. This must not be called from within
533
   * a loop that iterates over the tab panes using {@code forEach}, lest a
534
   * concurrent modification exception be thrown.
535
   *
536
   * @param resource The {@link TextResource} to close, without confirming with
537
   *                 the user.
538
   */
539
  private void close( final TextResource resource ) {
540
    getTab( resource ).ifPresent(
541
      ( tab ) -> {
542
        close( tab );
543
        tab.getTabPane().getTabs().remove( tab );
544
      }
545
    );
546
  }
547
548
  /**
549
   * Answers whether the given {@link TextResource} may be closed.
550
   *
551
   * @param editor The {@link TextResource} to try closing.
552
   * @return {@code true} when the editor may be closed; {@code false} when
553
   * the user has requested to keep the editor open.
554
   */
555
  private boolean canClose( final TextResource editor ) {
556
    final var editorTab = getTab( editor );
557
    final var canClose = new AtomicBoolean( true );
558
559
    if( editor.isModified() ) {
560
      final var filename = new StringBuilder();
561
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
562
563
      final var message = sNotifier.createNotification(
564
        Messages.get( "Alert.file.close.title" ),
565
        Messages.get( "Alert.file.close.text" ),
566
        filename.toString()
567
      );
568
569
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
570
571
      dialog.showAndWait().ifPresent(
572
        save -> canClose.set( save == YES ? editor.save() : save == NO )
573
      );
574
    }
575
576
    return canClose.get();
577
  }
578
579
  private ObjectProperty<TextEditor> createActiveTextEditor() {
580
    final var editor = new SimpleObjectProperty<TextEditor>();
581
582
    editor.addListener( ( c, o, n ) -> {
583
      if( n != null ) {
584
        mPreview.setBaseUri( n.getPath() );
585
        process( n );
586
      }
587
    } );
588
589
    return editor;
590
  }
591
592
  /**
593
   * Adds the HTML preview tab to its own, singular tab pane.
594
   */
595
  public void viewPreview() {
596
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
597
  }
598
599
  /**
600
   * Adds the document outline tab to its own, singular tab pane.
601
   */
602
  public void viewOutline() {
603
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
604
  }
605
606
  public void viewStatistics() {
607
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
608
  }
609
610
  public void viewFiles() {
611
    try {
612
      final var factory = new FilePickerFactory( mWorkspace );
613
      final var fileManager = factory.createModeless();
614
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
615
    } catch( final Exception ex ) {
616
      clue( ex );
617
    }
618
  }
619
620
  private void viewTab(
621
    final Node node, final MediaType mediaType, final String key ) {
622
    final var tabPane = obtainTabPane( mediaType );
623
624
    for( final var tab : tabPane.getTabs() ) {
625
      if( tab.getContent() == node ) {
626
        return;
627
      }
628
    }
629
630
    tabPane.getTabs().add( createTab( get( key ), node ) );
631
    addTabPane( tabPane );
632
  }
633
634
  public void viewRefresh() {
635
    mPreview.refresh();
636
  }
637
638
  /**
639
   * Returns the tab that contains the given {@link TextEditor}.
640
   *
641
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
642
   * @return The first tab having content that matches the given tab.
643
   */
644
  private Optional<Tab> getTab( final TextResource editor ) {
645
    return mTabPanes.stream()
646
                    .flatMap( pane -> pane.getTabs().stream() )
647
                    .filter( tab -> editor.equals( tab.getContent() ) )
648
                    .findFirst();
649
  }
650
651
  /**
652
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
653
   * is used to detect when the active {@link DefinitionEditor} has changed.
654
   * Upon changing, the {@link #mResolvedMap} is updated and the active
655
   * text editor is refreshed.
656
   *
657
   * @param editor Text editor to update with the revised resolved map.
658
   * @return A newly configured property that represents the active
659
   * {@link DefinitionEditor}, never null.
660
   */
661
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
662
    final ObjectProperty<TextEditor> editor ) {
663
    final var definitions = new SimpleObjectProperty<TextDefinition>();
664
    definitions.addListener( ( c, o, n ) -> {
665
      resolve( n == null ? createDefinitionEditor() : n );
666
      process( editor.get() );
667
    } );
668
669
    return definitions;
670
  }
671
672
  private Tab createTab( final String filename, final Node node ) {
673
    return new DetachableTab( filename, node );
674
  }
675
676
  private Tab createTab( final File file ) {
677
    final var r = createTextResource( file );
678
    final var tab = createTab( r.getFilename(), r.getNode() );
679
680
    r.modifiedProperty().addListener(
681
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
682
    );
683
684
    // This is called when either the tab is closed by the user clicking on
685
    // the tab's close icon or when closing (all) from the file menu.
686
    tab.setOnClosed(
687
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
688
    );
15
import com.keenwrite.preview.HtmlPreview;
16
import com.keenwrite.processors.Processor;
17
import com.keenwrite.processors.ProcessorContext;
18
import com.keenwrite.processors.ProcessorFactory;
19
import com.keenwrite.processors.markdown.extensions.CaretExtension;
20
import com.keenwrite.service.events.Notifier;
21
import com.keenwrite.sigils.RSigilOperator;
22
import com.keenwrite.sigils.SigilOperator;
23
import com.keenwrite.sigils.Tokens;
24
import com.keenwrite.sigils.YamlSigilOperator;
25
import com.keenwrite.ui.explorer.FilePickerFactory;
26
import com.keenwrite.ui.heuristics.DocumentStatistics;
27
import com.keenwrite.ui.outline.DocumentOutline;
28
import com.panemu.tiwulfx.control.dock.DetachableTab;
29
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
30
import javafx.application.Platform;
31
import javafx.beans.property.*;
32
import javafx.collections.ListChangeListener;
33
import javafx.concurrent.Task;
34
import javafx.event.ActionEvent;
35
import javafx.event.Event;
36
import javafx.event.EventHandler;
37
import javafx.scene.Node;
38
import javafx.scene.Scene;
39
import javafx.scene.control.*;
40
import javafx.scene.control.TreeItem.TreeModificationEvent;
41
import javafx.scene.input.KeyEvent;
42
import javafx.scene.layout.FlowPane;
43
import javafx.stage.Stage;
44
import javafx.stage.Window;
45
import org.greenrobot.eventbus.Subscribe;
46
47
import java.io.File;
48
import java.io.FileNotFoundException;
49
import java.nio.file.Path;
50
import java.util.*;
51
import java.util.concurrent.ExecutorService;
52
import java.util.concurrent.ScheduledExecutorService;
53
import java.util.concurrent.ScheduledFuture;
54
import java.util.concurrent.atomic.AtomicBoolean;
55
import java.util.concurrent.atomic.AtomicReference;
56
import java.util.function.Function;
57
import java.util.stream.Collectors;
58
59
import static com.keenwrite.ExportFormat.NONE;
60
import static com.keenwrite.Messages.get;
61
import static com.keenwrite.constants.Constants.*;
62
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
63
import static com.keenwrite.events.Bus.register;
64
import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
65
import static com.keenwrite.events.StatusEvent.clue;
66
import static com.keenwrite.io.MediaType.*;
67
import static com.keenwrite.preferences.WorkspaceKeys.*;
68
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
69
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
70
import static java.lang.String.format;
71
import static java.lang.System.getProperty;
72
import static java.util.concurrent.Executors.newFixedThreadPool;
73
import static java.util.concurrent.Executors.newScheduledThreadPool;
74
import static java.util.concurrent.TimeUnit.SECONDS;
75
import static java.util.stream.Collectors.groupingBy;
76
import static javafx.application.Platform.runLater;
77
import static javafx.scene.control.Alert.AlertType.ERROR;
78
import static javafx.scene.control.ButtonType.*;
79
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
80
import static javafx.scene.input.KeyCode.SPACE;
81
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
82
import static javafx.util.Duration.millis;
83
import static javax.swing.SwingUtilities.invokeLater;
84
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
85
86
/**
87
 * Responsible for wiring together the main application components for a
88
 * particular workspace (project). These include the definition views,
89
 * text editors, and preview pane along with any corresponding controllers.
90
 */
91
public final class MainPane extends SplitPane {
92
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
93
94
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
95
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
96
    new AtomicReference<>();
97
98
  private static final Notifier sNotifier = Services.load( Notifier.class );
99
100
  /**
101
   * Used when opening files to determine how each file should be binned and
102
   * therefore what tab pane to be opened within.
103
   */
104
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
105
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
106
  );
107
108
  /**
109
   * Prevents re-instantiation of processing classes.
110
   */
111
  private final Map<TextResource, Processor<String>> mProcessors =
112
    new HashMap<>();
113
114
  private final Workspace mWorkspace;
115
116
  /**
117
   * Groups similar file type tabs together.
118
   */
119
  private final List<TabPane> mTabPanes = new ArrayList<>();
120
121
  /**
122
   * Stores definition names and values.
123
   */
124
  private final Map<String, String> mResolvedMap =
125
    new HashMap<>( MAP_SIZE_DEFAULT );
126
127
  /**
128
   * Renders the actively selected plain text editor tab.
129
   */
130
  private final HtmlPreview mPreview;
131
132
  /**
133
   * Provides an interactive document outline.
134
   */
135
  private final DocumentOutline mOutline = new DocumentOutline();
136
137
  /**
138
   * Changing the active editor fires the value changed event. This allows
139
   * refreshes to happen when external definitions are modified and need to
140
   * trigger the processing chain.
141
   */
142
  private final ObjectProperty<TextEditor> mActiveTextEditor =
143
    createActiveTextEditor();
144
145
  /**
146
   * Changing the active definition editor fires the value changed event. This
147
   * allows refreshes to happen when external definitions are modified and need
148
   * to trigger the processing chain.
149
   */
150
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
151
    createActiveDefinitionEditor( mActiveTextEditor );
152
153
  /**
154
   * Tracks the number of detached tab panels opened into their own windows,
155
   * which allows unique identification of subordinate windows by their title.
156
   * It is doubtful more than 128 windows, much less 256, will be created.
157
   */
158
  private byte mWindowCount;
159
160
  /**
161
   * Called when the definition data is changed.
162
   */
163
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
164
    event -> {
165
      final var editor = mActiveDefinitionEditor.get();
166
167
      resolve( editor );
168
      process( getActiveTextEditor() );
169
      save( editor );
170
    };
171
172
  private final DocumentStatistics mStatistics;
173
174
  /**
175
   * Adds all content panels to the main user interface. This will load the
176
   * configuration settings from the workspace to reproduce the settings from
177
   * a previous session.
178
   */
179
  public MainPane( final Workspace workspace ) {
180
    mWorkspace = workspace;
181
    mPreview = new HtmlPreview( workspace );
182
    mStatistics = new DocumentStatistics( workspace );
183
    mActiveTextEditor.set( new MarkdownEditor( workspace ) );
184
185
    open( bin( getRecentFiles() ) );
186
    viewPreview();
187
    setDividerPositions( calculateDividerPositions() );
188
189
    // Once the main scene's window regains focus, update the active definition
190
    // editor to the currently selected tab.
191
    runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
192
      // Order matters here. We want to close all the tabs to ensure each
193
      // is saved, but after they are closed, the workspace should still
194
      // retain the list of files that were open. If this line came after
195
      // closing, then restarting the application would list no files.
196
      mWorkspace.save();
197
198
      if( closeAll() ) {
199
        Platform.exit();
200
        System.exit( 0 );
201
      }
202
      else {
203
        event.consume();
204
      }
205
    } ) );
206
207
    register( this );
208
    initAutosave( workspace );
209
  }
210
211
  @Subscribe
212
  public void handle( final TextEditorFocusEvent event ) {
213
    mActiveTextEditor.set( event.get() );
214
  }
215
216
  @Subscribe
217
  public void handle( final TextDefinitionFocusEvent event ) {
218
    mActiveDefinitionEditor.set( event.get() );
219
  }
220
221
  /**
222
   * Typically called when a file name is clicked in the preview panel.
223
   *
224
   * @param event The event to process, must contain a valid file reference.
225
   */
226
  @Subscribe
227
  public void handle( final FileOpenEvent event ) {
228
    final File eventFile;
229
    final var eventUri = event.getUri();
230
231
    if( eventUri.isAbsolute() ) {
232
      eventFile = new File( eventUri.getPath() );
233
    }
234
    else {
235
      final var activeFile = getActiveTextEditor().getFile();
236
      final var parent = activeFile.getParentFile();
237
238
      if( parent == null ) {
239
        clue( new FileNotFoundException( eventUri.getPath() ) );
240
        return;
241
      }
242
      else {
243
        final var parentPath = parent.getAbsolutePath();
244
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
245
      }
246
    }
247
248
    runLater( () -> open( eventFile ) );
249
  }
250
251
  @Subscribe
252
  public void handle( final CaretNavigationEvent event ) {
253
    runLater( () -> {
254
      final var textArea = getActiveTextEditor().getTextArea();
255
      textArea.moveTo( event.getOffset() );
256
      textArea.requestFollowCaret();
257
      textArea.requestFocus();
258
    } );
259
  }
260
261
  @Subscribe
262
  @SuppressWarnings( "unused" )
263
  public void handle( final ExportFailedEvent event ) {
264
    final var os = getProperty( "os.name" );
265
    final var arch = getProperty( "os.arch" ).toLowerCase();
266
    final var bits = getProperty( "sun.arch.data.model" );
267
268
    final var title = Messages.get( "Alert.typesetter.missing.title" );
269
    final var header = Messages.get( "Alert.typesetter.missing.header" );
270
    final var version = Messages.get(
271
      "Alert.typesetter.missing.version",
272
      os,
273
      arch
274
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
275
        .replaceAll( "mips.*", "MIPS" )
276
        .replaceAll( "armv.*", "ARM" ),
277
      bits );
278
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
279
280
    // Download and install ConTeXt for {0} {1} {2}-bit
281
    final var content = format( "%s %s", text, version );
282
    final var flowPane = new FlowPane();
283
    final var link = new Hyperlink( text );
284
    final var label = new Label( version );
285
    flowPane.getChildren().addAll( link, label );
286
287
    final var alert = new Alert( ERROR, content, OK );
288
    alert.setTitle( title );
289
    alert.setHeaderText( header );
290
    alert.getDialogPane().contentProperty().set( flowPane );
291
    alert.setGraphic( ICON_DIALOG_NODE );
292
293
    link.setOnAction( ( e ) -> {
294
      alert.close();
295
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
296
      runLater( () -> fireHyperlinkOpenEvent( url ) );
297
    } );
298
299
    alert.showAndWait();
300
  }
301
302
  private void initAutosave( final Workspace workspace ) {
303
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
304
305
    rate.addListener(
306
      ( c, o, n ) -> {
307
        final var taskRef = mSaveTask.get();
308
309
        // Prevent multiple autosaves from running.
310
        if( taskRef != null ) {
311
          taskRef.cancel( false );
312
        }
313
314
        initAutosave( rate );
315
      }
316
    );
317
318
    // Start the save listener (avoids duplicating some code).
319
    initAutosave( rate );
320
  }
321
322
  private void initAutosave( final IntegerProperty rate ) {
323
    mSaveTask.set(
324
      mSaver.scheduleAtFixedRate(
325
        () -> {
326
          if( getActiveTextEditor().isModified() ) {
327
            // Ensure the modified indicator is cleared by running on EDT.
328
            runLater( this::save );
329
          }
330
        }, 0, rate.intValue(), SECONDS
331
      )
332
    );
333
  }
334
335
  /**
336
   * TODO: Load divider positions from exported settings, see
337
   *   {@link #bin(SetProperty)} comment.
338
   */
339
  private double[] calculateDividerPositions() {
340
    final var ratio = 100f / getItems().size() / 100;
341
    final var positions = getDividerPositions();
342
343
    for( int i = 0; i < positions.length; i++ ) {
344
      positions[ i ] = ratio * i;
345
    }
346
347
    return positions;
348
  }
349
350
  /**
351
   * Opens all the files into the application, provided the paths are unique.
352
   * This may only be called for any type of files that a user can edit
353
   * (i.e., update and persist), such as definitions and text files.
354
   *
355
   * @param files The list of files to open.
356
   */
357
  public void open( final List<File> files ) {
358
    files.forEach( this::open );
359
  }
360
361
  /**
362
   * This opens the given file. Since the preview pane is not a file that
363
   * can be opened, it is safe to add a listener to the detachable pane.
364
   *
365
   * @param file The file to open.
366
   */
367
  private void open( final File file ) {
368
    final var tab = createTab( file );
369
    final var node = tab.getContent();
370
    final var mediaType = MediaType.valueFrom( file );
371
    final var tabPane = obtainTabPane( mediaType );
372
373
    tab.setTooltip( createTooltip( file ) );
374
    tabPane.setFocusTraversable( false );
375
    tabPane.setTabClosingPolicy( ALL_TABS );
376
    tabPane.getTabs().add( tab );
377
378
    // Attach the tab scene factory for new tab panes.
379
    if( !getItems().contains( tabPane ) ) {
380
      addTabPane(
381
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
382
      );
383
    }
384
385
    getRecentFiles().add( file.getAbsolutePath() );
386
  }
387
388
  /**
389
   * Opens a new text editor document using the default document file name.
390
   */
391
  public void newTextEditor() {
392
    open( DOCUMENT_DEFAULT );
393
  }
394
395
  /**
396
   * Opens a new definition editor document using the default definition
397
   * file name.
398
   */
399
  public void newDefinitionEditor() {
400
    open( DEFINITION_DEFAULT );
401
  }
402
403
  /**
404
   * Iterates over all tab panes to find all {@link TextEditor}s and request
405
   * that they save themselves.
406
   */
407
  public void saveAll() {
408
    mTabPanes.forEach(
409
      ( tp ) -> tp.getTabs().forEach( ( tab ) -> {
410
        final var node = tab.getContent();
411
        if( node instanceof final TextEditor editor ) {
412
          save( editor );
413
        }
414
      } )
415
    );
416
  }
417
418
  /**
419
   * Requests that the active {@link TextEditor} saves itself. Don't bother
420
   * checking if modified first because if the user swaps external media from
421
   * an external source (e.g., USB thumb drive), save should not second-guess
422
   * the user: save always re-saves. Also, it's less code.
423
   */
424
  public void save() {
425
    save( getActiveTextEditor() );
426
  }
427
428
  /**
429
   * Saves the active {@link TextEditor} under a new name.
430
   *
431
   * @param files The new active editor {@link File} reference, must contain
432
   *              at least one element.
433
   */
434
  public void saveAs( final List<File> files ) {
435
    assert files != null;
436
    assert !files.isEmpty();
437
    final var editor = getActiveTextEditor();
438
    final var tab = getTab( editor );
439
    final var file = files.get( 0 );
440
441
    editor.rename( file );
442
    tab.ifPresent( t -> {
443
      t.setText( editor.getFilename() );
444
      t.setTooltip( createTooltip( file ) );
445
    } );
446
447
    save();
448
  }
449
450
  /**
451
   * Saves the given {@link TextResource} to a file. This is typically used
452
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
453
   *
454
   * @param resource The resource to export.
455
   */
456
  private void save( final TextResource resource ) {
457
    try {
458
      resource.save();
459
    } catch( final Exception ex ) {
460
      clue( ex );
461
      sNotifier.alert(
462
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
463
      );
464
    }
465
  }
466
467
  /**
468
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
469
   *
470
   * @return {@code true} when all editors, modified or otherwise, were
471
   * permitted to close; {@code false} when one or more editors were modified
472
   * and the user requested no closing.
473
   */
474
  public boolean closeAll() {
475
    var closable = true;
476
477
    for( final var tabPane : mTabPanes ) {
478
      final var tabIterator = tabPane.getTabs().iterator();
479
480
      while( tabIterator.hasNext() ) {
481
        final var tab = tabIterator.next();
482
        final var resource = tab.getContent();
483
484
        // The definition panes auto-save, so being specific here prevents
485
        // closing the definitions in the situation where the user wants to
486
        // continue editing (i.e., possibly save unsaved work).
487
        if( !(resource instanceof TextEditor) ) {
488
          continue;
489
        }
490
491
        if( canClose( (TextEditor) resource ) ) {
492
          tabIterator.remove();
493
          close( tab );
494
        }
495
        else {
496
          closable = false;
497
        }
498
      }
499
    }
500
501
    return closable;
502
  }
503
504
  /**
505
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
506
   * event.
507
   *
508
   * @param tab The {@link Tab} that was closed.
509
   */
510
  private void close( final Tab tab ) {
511
    assert tab != null;
512
513
    final var handler = tab.getOnClosed();
514
515
    if( handler != null ) {
516
      handler.handle( new ActionEvent() );
517
    }
518
  }
519
520
  /**
521
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
522
   */
523
  public void close() {
524
    final var editor = getActiveTextEditor();
525
526
    if( canClose( editor ) ) {
527
      close( editor );
528
    }
529
  }
530
531
  /**
532
   * Closes the given {@link TextResource}. This must not be called from within
533
   * a loop that iterates over the tab panes using {@code forEach}, lest a
534
   * concurrent modification exception be thrown.
535
   *
536
   * @param resource The {@link TextResource} to close, without confirming with
537
   *                 the user.
538
   */
539
  private void close( final TextResource resource ) {
540
    getTab( resource ).ifPresent(
541
      ( tab ) -> {
542
        close( tab );
543
        tab.getTabPane().getTabs().remove( tab );
544
      }
545
    );
546
  }
547
548
  /**
549
   * Answers whether the given {@link TextResource} may be closed.
550
   *
551
   * @param editor The {@link TextResource} to try closing.
552
   * @return {@code true} when the editor may be closed; {@code false} when
553
   * the user has requested to keep the editor open.
554
   */
555
  private boolean canClose( final TextResource editor ) {
556
    final var editorTab = getTab( editor );
557
    final var canClose = new AtomicBoolean( true );
558
559
    if( editor.isModified() ) {
560
      final var filename = new StringBuilder();
561
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
562
563
      final var message = sNotifier.createNotification(
564
        Messages.get( "Alert.file.close.title" ),
565
        Messages.get( "Alert.file.close.text" ),
566
        filename.toString()
567
      );
568
569
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
570
571
      dialog.showAndWait().ifPresent(
572
        save -> canClose.set( save == YES ? editor.save() : save == NO )
573
      );
574
    }
575
576
    return canClose.get();
577
  }
578
579
  private ObjectProperty<TextEditor> createActiveTextEditor() {
580
    final var editor = new SimpleObjectProperty<TextEditor>();
581
582
    editor.addListener( ( c, o, n ) -> {
583
      if( n != null ) {
584
        mPreview.setBaseUri( n.getPath() );
585
        process( n );
586
      }
587
    } );
588
589
    return editor;
590
  }
591
592
  /**
593
   * Adds the HTML preview tab to its own, singular tab pane.
594
   */
595
  public void viewPreview() {
596
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
597
  }
598
599
  /**
600
   * Adds the document outline tab to its own, singular tab pane.
601
   */
602
  public void viewOutline() {
603
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
604
  }
605
606
  public void viewStatistics() {
607
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
608
  }
609
610
  public void viewFiles() {
611
    try {
612
      final var factory = new FilePickerFactory( mWorkspace );
613
      final var fileManager = factory.createModeless();
614
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
615
    } catch( final Exception ex ) {
616
      clue( ex );
617
    }
618
  }
619
620
  private void viewTab(
621
    final Node node, final MediaType mediaType, final String key ) {
622
    final var tabPane = obtainTabPane( mediaType );
623
624
    for( final var tab : tabPane.getTabs() ) {
625
      if( tab.getContent() == node ) {
626
        return;
627
      }
628
    }
629
630
    tabPane.getTabs().add( createTab( get( key ), node ) );
631
    addTabPane( tabPane );
632
  }
633
634
  public void viewRefresh() {
635
    mPreview.refresh();
636
  }
637
638
  /**
639
   * Returns the tab that contains the given {@link TextEditor}.
640
   *
641
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
642
   * @return The first tab having content that matches the given tab.
643
   */
644
  private Optional<Tab> getTab( final TextResource editor ) {
645
    return mTabPanes.stream()
646
                    .flatMap( pane -> pane.getTabs().stream() )
647
                    .filter( tab -> editor.equals( tab.getContent() ) )
648
                    .findFirst();
649
  }
650
651
  /**
652
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
653
   * is used to detect when the active {@link DefinitionEditor} has changed.
654
   * Upon changing, the {@link #mResolvedMap} is updated and the active
655
   * text editor is refreshed.
656
   *
657
   * @param editor Text editor to update with the revised resolved map.
658
   * @return A newly configured property that represents the active
659
   * {@link DefinitionEditor}, never null.
660
   */
661
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
662
    final ObjectProperty<TextEditor> editor ) {
663
    final var definitions = new SimpleObjectProperty<TextDefinition>();
664
    definitions.addListener( ( c, o, n ) -> {
665
      resolve( n == null ? createDefinitionEditor() : n );
666
      process( editor.get() );
667
    } );
668
669
    return definitions;
670
  }
671
672
  private Tab createTab( final String filename, final Node node ) {
673
    return new DetachableTab( filename, node );
674
  }
675
676
  private Tab createTab( final File file ) {
677
    final var r = createTextResource( file );
678
    final var tab = createTab( r.getFilename(), r.getNode() );
679
680
    r.modifiedProperty().addListener(
681
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
682
    );
683
684
    // This is called when either the tab is closed by the user clicking on
685
    // the tab's close icon or when closing (all) from the file menu.
686
    tab.setOnClosed(
687
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
688
    );
689
690
    // When closing a tab, give focus to the newly revealed tab.
691
    tab.selectedProperty().addListener( ( c, o, n ) -> {
692
      if( n != null && n ) {
693
        final var pane = tab.getTabPane();
694
695
        if( pane != null ) {
696
          pane.requestFocus();
697
        }
698
      }
699
    } );
689700
690701
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
M src/main/java/com/keenwrite/editors/definition/TreeItemMapper.java
33
44
import com.fasterxml.jackson.databind.JsonNode;
5
import com.keenwrite.preview.HtmlPreview;
65
import javafx.scene.control.TreeItem;
76
import javafx.scene.control.TreeView;
...
2726
 * <p>
2827
 * This class is responsible for producing the interpolated flat map. This
29
 * allows dynamic edits of the {@link TreeView} to be displayed in the
30
 * {@link HtmlPreview} without having to reload the definition file.
31
 * Reloading the definition file would work, but has a number of drawbacks.
28
 * allows dynamic edits of the {@link TreeView} to be displayed without
29
 * having to reload the definition file. Reloading the definition file would
30
 * work, but has a number of drawbacks.
3231
 * </p>
3332
 */
M src/main/java/com/keenwrite/events/DocumentChangedEvent.java
22
package com.keenwrite.events;
33
4
import org.jsoup.nodes.Document;
5
64
/**
75
 * Collates information about an HTML document that has changed.
...
3432
   * @param html The document that may have changed.
3533
   */
36
  public static void fireDocumentChangedEvent( final Document html ) {
34
  public static void fireDocumentChangedEvent( final String html ) {
3735
    // Hashing the document text ignores caret position changes.
38
    final var text = html.wholeText();
39
    final var hash = text.hashCode();
36
    final var hash = html.hashCode();
4037
4138
    if( hash != sHash ) {
4239
      sHash = hash;
43
      new DocumentChangedEvent( text ).fire();
40
      new DocumentChangedEvent( html ).fire();
4441
    }
4542
  }
M src/main/java/com/keenwrite/events/FileOpenEvent.java
22
package com.keenwrite.events;
33
4
import com.keenwrite.preview.HtmlPanel;
5
64
import java.net.URI;
75
86
/**
97
 * Collates information about a file requested to be opened. This can be called
10
 * when the user clicks a hyperlink in the {@link HtmlPanel}.
8
 * when the user clicks a hyperlink in HTML preview panel.
119
 */
1210
public class FileOpenEvent implements AppEvent {
M src/main/java/com/keenwrite/preferences/Workspace.java
6161
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
6262
 *   <dt>Dir</dt>
63
 *   <dd>Directory without a file name ({@link File#isDirectory()} is true)
64
 *   .</dd>
63
 *   <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
6564
 * </dl>
6665
 */
A src/main/java/com/keenwrite/preview/FlyingSaucerPanel.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import com.keenwrite.ui.adapters.DocumentAdapter;
5
import javafx.beans.property.BooleanProperty;
6
import javafx.beans.property.SimpleBooleanProperty;
7
import org.w3c.dom.Document;
8
import org.xhtmlrenderer.layout.SharedContext;
9
import org.xhtmlrenderer.render.Box;
10
import org.xhtmlrenderer.simple.XHTMLPanel;
11
import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler;
12
import org.xhtmlrenderer.swing.*;
13
14
import javax.swing.*;
15
import java.awt.*;
16
import java.awt.event.ComponentAdapter;
17
import java.awt.event.ComponentEvent;
18
import java.net.URI;
19
20
import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
21
import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
22
import static com.keenwrite.events.StatusEvent.clue;
23
import static com.keenwrite.util.ProtocolScheme.getProtocol;
24
import static java.lang.Boolean.FALSE;
25
import static java.lang.Boolean.TRUE;
26
import static java.lang.Math.max;
27
import static java.lang.Thread.sleep;
28
import static javax.swing.SwingUtilities.invokeLater;
29
30
/**
31
 * Responsible for configuring FlyingSaucer's {@link XHTMLPanel}.
32
 */
33
public final class FlyingSaucerPanel extends XHTMLPanel implements
34
  HtmlRenderer {
35
36
  /**
37
   * Suppresses scroll attempts until after the document has loaded.
38
   */
39
  private static final class DocumentEventHandler extends DocumentAdapter {
40
    private final BooleanProperty mReadyProperty = new SimpleBooleanProperty();
41
42
    @Override
43
    public void documentStarted() {
44
      mReadyProperty.setValue( FALSE );
45
    }
46
47
    @Override
48
    public void documentLoaded() {
49
      mReadyProperty.setValue( TRUE );
50
    }
51
  }
52
53
  /**
54
   * Ensures that the preview panel fills its container's area completely.
55
   */
56
  private final class ComponentEventHandler extends ComponentAdapter {
57
    /**
58
     * Invoked when the component's size changes.
59
     */
60
    public void componentResized( final ComponentEvent e ) {
61
      setPreferredSize( e.getComponent().getPreferredSize() );
62
    }
63
  }
64
65
  /**
66
   * Responsible for opening hyperlinks. External hyperlinks are opened in
67
   * the system's default browser; local file system links are opened in the
68
   * editor.
69
   */
70
  private static final class HyperlinkListener extends LinkListener {
71
    @Override
72
    public void linkClicked( final BasicPanel panel, final String link ) {
73
      try {
74
        final var uri = new URI( link );
75
76
        switch( getProtocol( uri ) ) {
77
          case HTTP -> fireHyperlinkOpenEvent( uri );
78
          case FILE -> fireFileOpenEvent( uri );
79
        }
80
      } catch( final Exception ex ) {
81
        clue( ex );
82
      }
83
    }
84
  }
85
86
  private static final XhtmlNamespaceHandler XNH = new XhtmlNamespaceHandler();
87
  private final ChainedReplacedElementFactory mFactory;
88
89
  FlyingSaucerPanel() {
90
    // The order is important: SwingReplacedElementFactory replaces SVG images
91
    // with a blank image, which will cause the chained factory to cache the
92
    // image and exit. Instead, the SVG must execute first to rasterize the
93
    // content. Consequently, the chained factory must maintain insertion order.
94
    mFactory = new ChainedReplacedElementFactory(
95
      new SvgReplacedElementFactory(),
96
      new SwingReplacedElementFactory()
97
    );
98
99
    final var context = getSharedContext();
100
    final var textRenderer = context.getTextRenderer();
101
    context.setReplacedElementFactory( mFactory );
102
    textRenderer.setSmoothingThreshold( 0 );
103
104
    addDocumentListener( new DocumentEventHandler() );
105
    removeMouseTrackingListeners();
106
    addMouseTrackingListener( new HyperlinkListener() );
107
    addComponentListener( new ComponentEventHandler() );
108
  }
109
110
  /**
111
   * Updates the document model displayed by the renderer. Effectively, this
112
   * updates the HTML document to provide new content.
113
   *
114
   * @param doc     A complete HTML5 document, including doctype.
115
   * @param baseUri URI to use for finding relative files, such as images.
116
   */
117
  @Override
118
  public void render( final Document doc, final String baseUri ) {
119
    setDocument( doc, baseUri, XNH );
120
  }
121
122
  @Override
123
  public void clearCache() {
124
    mFactory.clearCache();
125
  }
126
127
  @Override
128
  public void scrollTo(final String id, final JScrollPane scrollPane) {
129
    int iter = 0;
130
    Box box = null;
131
132
    while( iter++ < 3 && ((box = getBoxById( id )) == null) ) {
133
      try {
134
        sleep( 10 );
135
      } catch( final Exception ex ) {
136
        clue( ex );
137
      }
138
    }
139
140
    scrollTo( box, scrollPane );
141
  }
142
143
  /**
144
   * Scrolls to the location specified by the {@link Box} that corresponds
145
   * to a point somewhere in the preview pane. If there is no caret, then
146
   * this will not change the scroll position. Changing the scroll position
147
   * to the top if the {@link Box} instance is {@code null} will result in
148
   * jumping around a lot and inconsistent synchronization issues.
149
   *
150
   * @param box The rectangular region containing the caret, or {@code null}
151
   *            if the HTML does not have a caret.
152
   */
153
  private void scrollTo( final Box box, final JScrollPane scrollPane ) {
154
    if( box != null ) {
155
      invokeLater( () -> {
156
        scrollTo( createPoint( box, scrollPane ) );
157
        scrollPane.repaint();
158
      } );
159
    }
160
  }
161
162
  /**
163
   * Creates a {@link Point} to use as a reference for scrolling to the area
164
   * described by the given {@link Box}. The {@link Box} coordinates are used
165
   * to populate the {@link Point}'s location, with minor adjustments for
166
   * vertical centering.
167
   *
168
   * @param box The {@link Box} that represents a scrolling anchor reference.
169
   * @return A coordinate suitable for scrolling to.
170
   */
171
  private Point createPoint( final Box box, final JScrollPane scrollPane ) {
172
    assert box != null;
173
174
    // Scroll back up by half the height of the scroll bar to keep the typing
175
    // area within the view port. Otherwise the view port will have jumped too
176
    // high up and the most recently typed letters won't be visible.
177
    int y = max( box.getAbsY() - scrollPane.getVerticalScrollBar().getHeight() / 2, 0 );
178
    int x = box.getAbsX();
179
180
    if( !box.getStyle().isInline() ) {
181
      final var margin = box.getMargin( getLayoutContext() );
182
      y += margin.top();
183
      x += margin.left();
184
    }
185
186
    return new Point( x, y );
187
  }
188
189
  /**
190
   * Delegates to the {@link SharedContext}.
191
   *
192
   * @param id The HTML element identifier to retrieve in {@link Box} form.
193
   * @return The {@link Box} that corresponds to the given element ID, or
194
   * {@code null} if none found.
195
   */
196
  Box getBoxById( final String id ) {
197
    return getSharedContext().getBoxById( id );
198
  }
199
200
  /**
201
   * Suppress scrolling to the top on updates.
202
   */
203
  @Override
204
  public void resetScrollPosition() {
205
  }
206
207
  /**
208
   * The default mouse click listener attempts navigation within the preview
209
   * panel. We want to usurp that behaviour to open the link in a
210
   * platform-specific browser.
211
   */
212
  private void removeMouseTrackingListeners() {
213
    for( final var listener : getMouseTrackingListeners() ) {
214
      if( !(listener instanceof HoverListener) ) {
215
        removeMouseTrackingListener( (FSMouseListener) listener );
216
      }
217
    }
218
  }
219
}
1220
D src/main/java/com/keenwrite/preview/HtmlPanel.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import com.keenwrite.dom.DocumentConverter;
5
import com.keenwrite.ui.adapters.DocumentAdapter;
6
import javafx.beans.property.BooleanProperty;
7
import javafx.beans.property.SimpleBooleanProperty;
8
import org.xhtmlrenderer.layout.SharedContext;
9
import org.xhtmlrenderer.render.Box;
10
import org.xhtmlrenderer.simple.XHTMLPanel;
11
import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler;
12
import org.xhtmlrenderer.swing.BasicPanel;
13
import org.xhtmlrenderer.swing.FSMouseListener;
14
import org.xhtmlrenderer.swing.HoverListener;
15
import org.xhtmlrenderer.swing.LinkListener;
16
17
import java.awt.event.ComponentAdapter;
18
import java.awt.event.ComponentEvent;
19
import java.net.URI;
20
21
import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent;
22
import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent;
23
import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent;
24
import static com.keenwrite.events.StatusEvent.clue;
25
import static com.keenwrite.util.ProtocolScheme.getProtocol;
26
import static java.lang.Boolean.FALSE;
27
import static java.lang.Boolean.TRUE;
28
import static javax.swing.SwingUtilities.invokeLater;
29
import static javax.swing.SwingUtilities.isEventDispatchThread;
30
import static org.jsoup.Jsoup.parse;
31
32
/**
33
 * Responsible for configuring FlyingSaucer's {@link XHTMLPanel}.
34
 */
35
public final class HtmlPanel extends XHTMLPanel {
36
37
  /**
38
   * Suppresses scroll attempts until after the document has loaded.
39
   */
40
  private static final class DocumentEventHandler extends DocumentAdapter {
41
    private final BooleanProperty mReadyProperty = new SimpleBooleanProperty();
42
43
    @Override
44
    public void documentStarted() {
45
      mReadyProperty.setValue( FALSE );
46
    }
47
48
    @Override
49
    public void documentLoaded() {
50
      mReadyProperty.setValue( TRUE );
51
    }
52
  }
53
54
  /**
55
   * Ensures that the preview panel fills its container's area completely.
56
   */
57
  private final class ComponentEventHandler extends ComponentAdapter {
58
    /**
59
     * Invoked when the component's size changes.
60
     */
61
    public void componentResized( final ComponentEvent e ) {
62
      setPreferredSize( e.getComponent().getPreferredSize() );
63
    }
64
  }
65
66
  /**
67
   * Responsible for opening hyperlinks. External hyperlinks are opened in
68
   * the system's default browser; local file system links are opened in the
69
   * editor.
70
   */
71
  private static final class HyperlinkListener extends LinkListener {
72
    @Override
73
    public void linkClicked( final BasicPanel panel, final String link ) {
74
      try {
75
        final var uri = new URI( link );
76
77
        switch( getProtocol( uri ) ) {
78
          case HTTP -> fireHyperlinkOpenEvent( uri );
79
          case FILE -> fireFileOpenEvent( uri );
80
        }
81
      } catch( final Exception ex ) {
82
        clue( ex );
83
      }
84
    }
85
  }
86
87
  private static final DocumentConverter CONVERTER = new DocumentConverter();
88
  private static final XhtmlNamespaceHandler XNH = new XhtmlNamespaceHandler();
89
90
  public HtmlPanel() {
91
    addDocumentListener( new DocumentEventHandler() );
92
    removeMouseTrackingListeners();
93
    addMouseTrackingListener( new HyperlinkListener() );
94
    addComponentListener( new ComponentEventHandler() );
95
  }
96
97
  /**
98
   * Updates the document model displayed by the renderer. Effectively, this
99
   * updates the HTML document to provide new content.
100
   *
101
   * @param html    A complete HTML5 document, including doctype.
102
   * @param baseUri URI to use for finding relative files, such as images.
103
   */
104
  public void render( final String html, final String baseUri ) {
105
    final var soup = parse( html );
106
    final var doc = CONVERTER.fromJsoup( soup );
107
    final Runnable renderDocument = () -> setDocument( doc, baseUri, XNH );
108
    doc.setDocumentURI( baseUri );
109
110
    // Access to a Swing component must occur from the Event Dispatch
111
    // Thread (EDT) according to Swing threading restrictions. Setting a new
112
    // document invokes a Swing repaint operation.
113
    if( isEventDispatchThread() ) {
114
      renderDocument.run();
115
    }
116
    else {
117
      invokeLater( renderDocument );
118
    }
119
120
    // When the text changes, let subscribers know. This allows for text
121
    // analysis to occur on a separate thread.
122
    fireDocumentChangedEvent( soup );
123
  }
124
125
  /**
126
   * Delegates to the {@link SharedContext}.
127
   *
128
   * @param id The HTML element identifier to retrieve in {@link Box} form.
129
   * @return The {@link Box} that corresponds to the given element ID, or
130
   * {@code null} if none found.
131
   */
132
  public Box getBoxById( final String id ) {
133
    return getSharedContext().getBoxById( id );
134
  }
135
136
  /**
137
   * Suppress scrolling to the top on updates.
138
   */
139
  @Override
140
  public void resetScrollPosition() {
141
  }
142
143
  /**
144
   * The default mouse click listener attempts navigation within the preview
145
   * panel. We want to usurp that behaviour to open the link in a
146
   * platform-specific browser.
147
   */
148
  private void removeMouseTrackingListeners() {
149
    for( final var listener : getMouseTrackingListeners() ) {
150
      if( !(listener instanceof HoverListener) ) {
151
        removeMouseTrackingListener( (FSMouseListener) listener );
152
      }
153
    }
154
  }
155
}
1561
M src/main/java/com/keenwrite/preview/HtmlPreview.java
22
package com.keenwrite.preview;
33
4
import com.keenwrite.events.ScrollLockEvent;
5
import com.keenwrite.preferences.LocaleProperty;
6
import com.keenwrite.preferences.Workspace;
7
import javafx.beans.property.DoubleProperty;
8
import javafx.beans.property.StringProperty;
9
import javafx.embed.swing.SwingNode;
10
import org.greenrobot.eventbus.Subscribe;
11
import org.xhtmlrenderer.render.Box;
12
import org.xhtmlrenderer.swing.SwingReplacedElementFactory;
13
14
import javax.swing.*;
15
import java.awt.*;
16
import java.awt.event.ComponentEvent;
17
import java.awt.event.ComponentListener;
18
import java.net.URL;
19
import java.nio.file.Path;
20
import java.util.Locale;
21
22
import static com.keenwrite.Messages.get;
23
import static com.keenwrite.constants.Constants.*;
24
import static com.keenwrite.events.Bus.register;
25
import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
26
import static com.keenwrite.events.StatusEvent.clue;
27
import static com.keenwrite.preferences.WorkspaceKeys.*;
28
import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
29
import static java.awt.BorderLayout.*;
30
import static java.awt.event.KeyEvent.*;
31
import static java.lang.Math.max;
32
import static java.lang.String.format;
33
import static java.lang.Thread.sleep;
34
import static javafx.scene.CacheHint.SPEED;
35
import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW;
36
import static javax.swing.KeyStroke.getKeyStroke;
37
import static javax.swing.SwingUtilities.invokeLater;
38
import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK;
39
import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT;
40
41
/**
42
 * Responsible for parsing an HTML document.
43
 */
44
public final class HtmlPreview extends SwingNode implements ComponentListener {
45
  /**
46
   * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
47
   */
48
  private static final String HTML_STYLESHEET =
49
    "<link rel='stylesheet' href='%s'/>";
50
51
  private static final String HTML_BASE =
52
    "<base href='%s'/>";
53
54
  /**
55
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
56
   * poor rendering. The {@link #generateHead()} method fills placeholders.
57
   * When the user has not set a locale, only one stylesheet is added to
58
   * the document. In order, the placeholders are as follows:
59
   * <ol>
60
   * <li>%s --- language</li>
61
   * <li>%s --- default stylesheet</li>
62
   * <li>%s --- language-specific stylesheet</li>
63
   * <li>%s --- user-customized stylesheet</li>
64
   * <li>%s --- font family</li>
65
   * <li>%d --- font size (must be pixels, not points due to bug)</li>
66
   * <li>%s --- base href</li>
67
   * </p>
68
   */
69
  private static final String HTML_HEAD =
70
    """
71
      <!doctype html>
72
      <html lang='%s'><head><title> </title><meta charset='utf-8'/>
73
      %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
74
      """;
75
76
  private static final String HTML_TAIL = "</body></html>";
77
78
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
79
80
  private final ChainedReplacedElementFactory mFactory;
81
82
  /**
83
   * Reusing this buffer prevents repetitious memory re-allocations.
84
   */
85
  private final StringBuilder mDocument = new StringBuilder( 65536 );
86
87
  private HtmlPanel mView;
88
  private JScrollPane mScrollPane;
89
  private String mBaseUriPath = "";
90
  private String mHead = "";
91
92
  private volatile boolean mLocked;
93
  private final JButton mScrollLockButton = new JButton();
94
  private final Workspace mWorkspace;
95
96
  /**
97
   * Creates a new preview pane that can scroll to the caret position within the
98
   * document.
99
   *
100
   * @param workspace Contains locale and font size information.
101
   */
102
  public HtmlPreview( final Workspace workspace ) {
103
    mWorkspace = workspace;
104
105
    // The order is important: SwingReplacedElementFactory replaces SVG images
106
    // with a blank image, which will cause the chained factory to cache the
107
    // image and exit. Instead, the SVG must execute first to rasterize the
108
    // content. Consequently, the chained factory must maintain insertion order.
109
    mFactory = new ChainedReplacedElementFactory(
110
      new SvgReplacedElementFactory(),
111
      new SwingReplacedElementFactory()
112
    );
113
114
    // Attempts to prevent a flash of black un-styled content upon load.
115
    setStyle( "-fx-background-color: white;" );
116
117
    invokeLater( () -> {
118
      mHead = generateHead();
119
      mView = new HtmlPanel();
120
      mScrollPane = new JScrollPane( mView );
121
      final var verticalBar = mScrollPane.getVerticalScrollBar();
122
      final var verticalPanel = new JPanel( new BorderLayout() );
123
124
      final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
125
      addKeyboardEvents( map );
126
127
      mScrollLockButton.setFont( getIconFont( 14 ) );
128
      mScrollLockButton.setText( getLockText( mLocked ) );
129
      mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
130
      mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
131
132
      verticalPanel.add( verticalBar, CENTER );
133
      verticalPanel.add( mScrollLockButton, PAGE_END );
134
135
      final var wrapper = new JPanel( new BorderLayout() );
136
      wrapper.add( mScrollPane, CENTER );
137
      wrapper.add( verticalPanel, LINE_END );
138
139
      // Enabling the cache attempts to prevent black flashes when resizing.
140
      setCache( true );
141
      setCacheHint( SPEED );
142
      setContent( wrapper );
143
      wrapper.addComponentListener( this );
144
145
      final var context = mView.getSharedContext();
146
      final var textRenderer = context.getTextRenderer();
147
      context.setReplacedElementFactory( mFactory );
148
      textRenderer.setSmoothingThreshold( 0 );
149
150
      localeProperty().addListener( ( c, o, n ) -> rerender() );
151
      fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
152
      fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
153
    } );
154
155
    register( this );
156
  }
157
158
  @Subscribe
159
  public void handle( final ScrollLockEvent event ) {
160
    mLocked = event.isLocked();
161
    invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
162
  }
163
164
  /**
165
   * Updates the internal HTML source shown in the preview pane.
166
   *
167
   * @param html The new HTML document to display.
168
   */
169
  public void render( final String html ) {
170
    mView.render( decorate( html ), getBaseUri() );
171
  }
172
173
  /**
174
   * Clears the caches then re-renders the content.
175
   */
176
  public void refresh() {
177
    mFactory.clearCache();
178
    rerender();
179
  }
180
181
  /**
182
   * Recomputes the HTML head then renders the document.
183
   */
184
  private void rerender() {
185
    mHead = generateHead();
186
    render( mDocument.toString() );
187
  }
188
189
  /**
190
   * Attaches the HTML head prefix and HTML tail suffix to the given HTML
191
   * string.
192
   *
193
   * @param html The HTML to adorn with opening and closing tags.
194
   * @return A complete HTML document, ready for rendering.
195
   */
196
  private String decorate( final String html ) {
197
    mDocument.setLength( 0 );
198
    mDocument.append( html );
199
200
    // Head and tail must be separate from document due to re-rendering.
201
    return mHead + mDocument + HTML_TAIL;
202
  }
203
204
  /**
205
   * Called when settings are changed that affect the HTML document preamble.
206
   * This is a minor performance optimization to avoid generating the head
207
   * each time that the document itself changes.
208
   *
209
   * @return A new doctype and HTML {@code head} element.
210
   */
211
  private String generateHead() {
212
    final var locale = getLocale();
213
    final var base = getBaseUri();
214
    final var custom = getCustomStylesheetUrl();
215
216
    // Point sizes are converted to pixels because of a rendering bug.
217
    return format(
218
      HTML_HEAD,
219
      locale.getLanguage(),
220
      toStylesheetString( HTML_STYLE_PREVIEW ),
221
      toStylesheetString( toUrl( locale ) ),
222
      toStylesheetString( custom ),
223
      getFontFamily(),
224
      toPixels( getFontSize() ),
225
      base.isBlank() ? "" : format( HTML_BASE, base )
226
    );
227
  }
228
229
  /**
230
   * Clears the preview pane by rendering an empty string.
231
   */
232
  public void clear() {
233
    render( "" );
234
  }
235
236
  /**
237
   * Sets the base URI to the containing directory the file being edited.
238
   *
239
   * @param path The path to the file being edited.
240
   */
241
  public void setBaseUri( final Path path ) {
242
    final var parent = path.getParent();
243
    mBaseUriPath = parent == null ? "" : parent.toUri().toString();
244
  }
245
246
  /**
247
   * Scrolls to the closest element matching the given identifier without
248
   * waiting for the document to be ready.
249
   *
250
   * @param id Scroll the preview pane to this unique paragraph identifier.
251
   */
252
  public void scrollTo( final String id ) {
253
    if( mLocked ) {
254
      return;
255
    }
256
257
    invokeLater( () -> {
258
      int iter = 0;
259
      Box box = null;
260
261
      while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) {
262
        try {
263
          sleep( 10 );
264
        } catch( final Exception ex ) {
265
          clue( ex );
266
        }
267
      }
268
269
      scrollTo( box );
270
    } );
271
  }
272
273
  /**
274
   * Scrolls to the location specified by the {@link Box} that corresponds
275
   * to a point somewhere in the preview pane. If there is no caret, then
276
   * this will not change the scroll position. Changing the scroll position
277
   * to the top if the {@link Box} instance is {@code null} will result in
278
   * jumping around a lot and inconsistent synchronization issues.
279
   *
280
   * @param box The rectangular region containing the caret, or {@code null}
281
   *            if the HTML does not have a caret.
282
   */
283
  private void scrollTo( final Box box ) {
284
    if( box != null ) {
285
      invokeLater( () -> {
286
        mView.scrollTo( createPoint( box ) );
287
        getScrollPane().repaint();
288
      } );
289
    }
290
  }
291
292
  /**
293
   * Creates a {@link Point} to use as a reference for scrolling to the area
294
   * described by the given {@link Box}. The {@link Box} coordinates are used
295
   * to populate the {@link Point}'s location, with minor adjustments for
296
   * vertical centering.
297
   *
298
   * @param box The {@link Box} that represents a scrolling anchor reference.
299
   * @return A coordinate suitable for scrolling to.
300
   */
301
  private Point createPoint( final Box box ) {
302
    assert box != null;
303
304
    // Scroll back up by half the height of the scroll bar to keep the typing
305
    // area within the view port. Otherwise the view port will have jumped too
306
    // high up and the most recently typed letters won't be visible.
307
    int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 );
308
    int x = box.getAbsX();
309
310
    if( !box.getStyle().isInline() ) {
311
      final var margin = box.getMargin( mView.getLayoutContext() );
312
      y += margin.top();
313
      x += margin.left();
314
    }
315
316
    return new Point( x, y );
317
  }
318
319
  private String getBaseUri() {
320
    return mBaseUriPath;
321
  }
322
323
  private JScrollPane getScrollPane() {
324
    return mScrollPane;
325
  }
326
327
  public JScrollBar getVerticalScrollBar() {
328
    return getScrollPane().getVerticalScrollBar();
329
  }
330
331
  private int getVerticalScrollBarHeight() {
332
    return getVerticalScrollBar().getHeight();
333
  }
334
335
  /**
336
   * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
337
   * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
338
   * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
339
   * could return "en-Latn-CA" for Canadian English written in the Latin
340
   * character set.
341
   *
342
   * @return Unique identifier for language and country.
343
   */
344
  private static URL toUrl( final Locale locale ) {
345
    return toUrl(
346
      get(
347
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
348
        locale.getLanguage(),
349
        locale.getScript(),
350
        locale.getCountry()
351
      )
352
    );
353
  }
354
355
  private static URL toUrl( final String path ) {
356
    return HtmlPreview.class.getResource( path );
357
  }
358
359
  private Locale getLocale() {
360
    return localeProperty().toLocale();
361
  }
362
363
  private LocaleProperty localeProperty() {
364
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
365
  }
366
367
  private String getFontFamily() {
368
    return fontFamilyProperty().get();
369
  }
370
371
  private StringProperty fontFamilyProperty() {
372
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
373
  }
374
375
  private double getFontSize() {
376
    return fontSizeProperty().get();
377
  }
378
379
  /**
380
   * Returns the font size in points.
381
   *
382
   * @return The user-defined font size (in pt).
383
   */
384
  private DoubleProperty fontSizeProperty() {
385
    return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
386
  }
387
388
  private String getLockText( final boolean locked ) {
389
    return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
390
  }
391
392
  private URL getCustomStylesheetUrl() {
393
    try {
394
      return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL();
395
    } catch( final Exception ex ) {
396
      clue( ex );
397
      return null;
398
    }
399
  }
400
401
  /**
402
   * Maps keyboard events to scrollbar commands so that users may control
403
   * the {@link HtmlPreview} panel using the keyboard.
404
   *
405
   * @param map The map to update with keyboard events.
406
   */
407
  private void addKeyboardEvents( final InputMap map ) {
408
    map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
409
    map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
410
    map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
411
    map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
412
    map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
413
    map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
414
  }
415
416
  @Override
417
  public void componentResized( final ComponentEvent e ) {
418
    if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
419
      mFactory.clearCache();
420
    }
421
422
    // Force update on the Swing EDT, otherwise the scrollbar and content
423
    // will not be updated correctly on some platforms.
424
    invokeLater( () -> getContent().repaint() );
425
  }
426
427
  @Override
428
  public void componentMoved( final ComponentEvent e ) { }
429
430
  @Override
431
  public void componentShown( final ComponentEvent e ) { }
432
433
  @Override
434
  public void componentHidden( final ComponentEvent e ) { }
4
import com.keenwrite.dom.DocumentConverter;
5
import com.keenwrite.events.ScrollLockEvent;
6
import com.keenwrite.preferences.LocaleProperty;
7
import com.keenwrite.preferences.Workspace;
8
import javafx.beans.property.DoubleProperty;
9
import javafx.beans.property.StringProperty;
10
import javafx.embed.swing.SwingNode;
11
import org.greenrobot.eventbus.Subscribe;
12
13
import javax.swing.*;
14
import java.awt.*;
15
import java.awt.event.ComponentEvent;
16
import java.awt.event.ComponentListener;
17
import java.net.URL;
18
import java.nio.file.Path;
19
import java.util.Locale;
20
21
import static com.keenwrite.Messages.get;
22
import static com.keenwrite.constants.Constants.*;
23
import static com.keenwrite.events.Bus.register;
24
import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent;
25
import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
26
import static com.keenwrite.events.StatusEvent.clue;
27
import static com.keenwrite.preferences.WorkspaceKeys.*;
28
import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
29
import static java.awt.BorderLayout.*;
30
import static java.awt.event.KeyEvent.*;
31
import static java.lang.String.format;
32
import static javafx.scene.CacheHint.SPEED;
33
import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW;
34
import static javax.swing.KeyStroke.getKeyStroke;
35
import static javax.swing.SwingUtilities.invokeLater;
36
import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK;
37
import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT;
38
import static org.jsoup.Jsoup.parse;
39
40
/**
41
 * Responsible for parsing an HTML document.
42
 */
43
public final class HtmlPreview extends SwingNode implements ComponentListener {
44
  /**
45
   * Converts a text string to a structured HTML document.
46
   */
47
  private static final DocumentConverter CONVERTER = new DocumentConverter();
48
49
  /**
50
   * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
51
   */
52
  private static final String HTML_STYLESHEET =
53
    "<link rel='stylesheet' href='%s'/>";
54
55
  private static final String HTML_BASE =
56
    "<base href='%s'/>";
57
58
  /**
59
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
60
   * poor rendering. The {@link #generateHead()} method fills placeholders.
61
   * When the user has not set a locale, only one stylesheet is added to
62
   * the document. In order, the placeholders are as follows:
63
   * <ol>
64
   * <li>%s --- language</li>
65
   * <li>%s --- default stylesheet</li>
66
   * <li>%s --- language-specific stylesheet</li>
67
   * <li>%s --- user-customized stylesheet</li>
68
   * <li>%s --- font family</li>
69
   * <li>%d --- font size (must be pixels, not points due to bug)</li>
70
   * <li>%s --- base href</li>
71
   * </p>
72
   */
73
  private static final String HTML_HEAD =
74
    """
75
      <!doctype html>
76
      <html lang='%s'><head><title> </title><meta charset='utf-8'/>
77
      %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
78
      """;
79
80
  private static final String HTML_TAIL = "</body></html>";
81
82
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
83
84
  /**
85
   * Reusing this buffer prevents repetitious memory re-allocations.
86
   */
87
  private final StringBuilder mDocument = new StringBuilder( 65536 );
88
89
  private HtmlRenderer mPreview;
90
  private JScrollPane mScrollPane;
91
  private String mBaseUriPath = "";
92
  private String mHead;
93
94
  private volatile boolean mLocked;
95
  private final JButton mScrollLockButton = new JButton();
96
  private final Workspace mWorkspace;
97
98
  /**
99
   * Creates a new preview pane that can scroll to the caret position within the
100
   * document.
101
   *
102
   * @param workspace Contains locale and font size information.
103
   */
104
  public HtmlPreview( final Workspace workspace ) {
105
    mWorkspace = workspace;
106
    mHead = generateHead();
107
108
    // Attempts to prevent a flash of black un-styled content upon load.
109
    setStyle( "-fx-background-color: white;" );
110
111
    invokeLater( () -> {
112
      mPreview = new FlyingSaucerPanel();
113
      mScrollPane = new JScrollPane( (Component) mPreview );
114
      final var verticalBar = mScrollPane.getVerticalScrollBar();
115
      final var verticalPanel = new JPanel( new BorderLayout() );
116
117
      final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
118
      addKeyboardEvents( map );
119
120
      mScrollLockButton.setFont( getIconFont( 14 ) );
121
      mScrollLockButton.setText( getLockText( mLocked ) );
122
      mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
123
      mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
124
125
      verticalPanel.add( verticalBar, CENTER );
126
      verticalPanel.add( mScrollLockButton, PAGE_END );
127
128
      final var wrapper = new JPanel( new BorderLayout() );
129
      wrapper.add( mScrollPane, CENTER );
130
      wrapper.add( verticalPanel, LINE_END );
131
132
      // Enabling the cache attempts to prevent black flashes when resizing.
133
      setCache( true );
134
      setCacheHint( SPEED );
135
      setContent( wrapper );
136
      wrapper.addComponentListener( this );
137
    } );
138
139
    localeProperty().addListener( ( c, o, n ) -> rerender() );
140
    fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
141
    fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
142
143
    register( this );
144
  }
145
146
  @Subscribe
147
  public void handle( final ScrollLockEvent event ) {
148
    mLocked = event.isLocked();
149
    invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
150
  }
151
152
  /**
153
   * Updates the internal HTML source shown in the preview pane.
154
   *
155
   * @param html The new HTML document to display.
156
   */
157
  public void render( final String html ) {
158
    final var doc = CONVERTER.fromJsoup( parse( decorate( html ) ) );
159
    final var uri = getBaseUri();
160
    doc.setDocumentURI( uri );
161
162
    invokeLater( () -> mPreview.render( doc, uri ) );
163
164
    fireDocumentChangedEvent( html );
165
  }
166
167
  /**
168
   * Clears the caches then re-renders the content.
169
   */
170
  public void refresh() {
171
    mPreview.clearCache();
172
    rerender();
173
  }
174
175
  /**
176
   * Recomputes the HTML head then renders the document.
177
   */
178
  private void rerender() {
179
    mHead = generateHead();
180
    render( mDocument.toString() );
181
  }
182
183
  /**
184
   * Attaches the HTML head prefix and HTML tail suffix to the given HTML
185
   * string.
186
   *
187
   * @param html The HTML to adorn with opening and closing tags.
188
   * @return A complete HTML document, ready for rendering.
189
   */
190
  private String decorate( final String html ) {
191
    mDocument.setLength( 0 );
192
    mDocument.append( html );
193
194
    // Head and tail must be separate from document due to re-rendering.
195
    return mHead + mDocument + HTML_TAIL;
196
  }
197
198
  /**
199
   * Called when settings are changed that affect the HTML document preamble.
200
   * This is a minor performance optimization to avoid generating the head
201
   * each time that the document itself changes.
202
   *
203
   * @return A new doctype and HTML {@code head} element.
204
   */
205
  private String generateHead() {
206
    final var locale = getLocale();
207
    final var base = getBaseUri();
208
    final var custom = getCustomStylesheetUrl();
209
210
    // Point sizes are converted to pixels because of a rendering bug.
211
    return format(
212
      HTML_HEAD,
213
      locale.getLanguage(),
214
      toStylesheetString( HTML_STYLE_PREVIEW ),
215
      toStylesheetString( toUrl( locale ) ),
216
      toStylesheetString( custom ),
217
      getFontFamily(),
218
      toPixels( getFontSize() ),
219
      base.isBlank() ? "" : format( HTML_BASE, base )
220
    );
221
  }
222
223
  /**
224
   * Clears the preview pane by rendering an empty string.
225
   */
226
  public void clear() {
227
    render( "" );
228
  }
229
230
  /**
231
   * Sets the base URI to the containing directory the file being edited.
232
   *
233
   * @param path The path to the file being edited.
234
   */
235
  public void setBaseUri( final Path path ) {
236
    final var parent = path.getParent();
237
    mBaseUriPath = parent == null ? "" : parent.toUri().toString();
238
  }
239
240
  /**
241
   * Scrolls to the closest element matching the given identifier without
242
   * waiting for the document to be ready.
243
   *
244
   * @param id Scroll the preview pane to this unique paragraph identifier.
245
   */
246
  public void scrollTo( final String id ) {
247
    if( !mLocked ) {
248
      invokeLater( () -> {
249
        mPreview.scrollTo( id, mScrollPane );
250
        mScrollPane.repaint();
251
      } );
252
    }
253
  }
254
255
  private String getBaseUri() {
256
    return mBaseUriPath;
257
  }
258
259
  private JScrollPane getScrollPane() {
260
    return mScrollPane;
261
  }
262
263
  public JScrollBar getVerticalScrollBar() {
264
    return getScrollPane().getVerticalScrollBar();
265
  }
266
267
  /**
268
   * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
269
   * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
270
   * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
271
   * could return "en-Latn-CA" for Canadian English written in the Latin
272
   * character set.
273
   *
274
   * @return Unique identifier for language and country.
275
   */
276
  private static URL toUrl( final Locale locale ) {
277
    return toUrl(
278
      get(
279
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
280
        locale.getLanguage(),
281
        locale.getScript(),
282
        locale.getCountry()
283
      )
284
    );
285
  }
286
287
  private static URL toUrl( final String path ) {
288
    return HtmlPreview.class.getResource( path );
289
  }
290
291
  private Locale getLocale() {
292
    return localeProperty().toLocale();
293
  }
294
295
  private LocaleProperty localeProperty() {
296
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
297
  }
298
299
  private String getFontFamily() {
300
    return fontFamilyProperty().get();
301
  }
302
303
  private StringProperty fontFamilyProperty() {
304
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
305
  }
306
307
  private double getFontSize() {
308
    return fontSizeProperty().get();
309
  }
310
311
  /**
312
   * Returns the font size in points.
313
   *
314
   * @return The user-defined font size (in pt).
315
   */
316
  private DoubleProperty fontSizeProperty() {
317
    return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
318
  }
319
320
  private String getLockText( final boolean locked ) {
321
    return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
322
  }
323
324
  private URL getCustomStylesheetUrl() {
325
    try {
326
      return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL();
327
    } catch( final Exception ex ) {
328
      clue( ex );
329
      return null;
330
    }
331
  }
332
333
  /**
334
   * Maps keyboard events to scrollbar commands so that users may control
335
   * the {@link HtmlPreview} panel using the keyboard.
336
   *
337
   * @param map The map to update with keyboard events.
338
   */
339
  private void addKeyboardEvents( final InputMap map ) {
340
    map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
341
    map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
342
    map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
343
    map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
344
    map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
345
    map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
346
  }
347
348
  @Override
349
  public void componentResized( final ComponentEvent e ) {
350
    if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
351
      mPreview.clearCache();
352
    }
353
354
    // Force update on the Swing EDT, otherwise the scrollbar and content
355
    // will not be updated correctly on some platforms.
356
    invokeLater( () -> getContent().repaint() );
357
  }
358
359
  @Override
360
  public void componentMoved( final ComponentEvent e ) {}
361
362
  @Override
363
  public void componentShown( final ComponentEvent e ) {}
364
365
  @Override
366
  public void componentHidden( final ComponentEvent e ) {}
435367
436368
  private static String toStylesheetString( final URL url ) {
A src/main/java/com/keenwrite/preview/HtmlRenderer.java
1
package com.keenwrite.preview;
2
3
import org.w3c.dom.Document;
4
5
import javax.swing.*;
6
7
/**
8
 * Denotes the ability to render an HTML document onto a Swing component.
9
 */
10
public interface HtmlRenderer {
11
12
  /**
13
   * Renders an HTML document with respect to a base location.
14
   *
15
   * @param doc     The document to render.
16
   * @param baseUri The document's relative URI.
17
   */
18
  void render( final Document doc, final String baseUri );
19
20
  /**
21
   * Scrolls the given {@link JScrollPane} to the first HTML element that
22
   * has an {@code id} attribute that matches the given identifier.
23
   *
24
   * @param id         The HTML element identifier.
25
   * @param scrollPane The GUI widget that controls scrolling.
26
   */
27
  void scrollTo( final String id, final JScrollPane scrollPane );
28
29
  /**
30
   * Clears the cache (e.g., so that images are re-rendered using updated
31
   * dimensions).
32
   */
33
  void clearCache();
34
}
135
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
33
44
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
5
import org.apache.batik.css.parser.Parser;
6
import org.apache.batik.gvt.renderer.ImageRenderer;
7
import org.apache.batik.transcoder.TranscoderException;
8
import org.apache.batik.transcoder.TranscoderInput;
9
import org.apache.batik.transcoder.TranscoderOutput;
10
import org.apache.batik.transcoder.image.ImageTranscoder;
11
import org.apache.batik.util.XMLResourceDescriptor;
12
import org.w3c.css.sac.CSSException;
13
import org.w3c.dom.Document;
14
import org.w3c.dom.Element;
15
16
import java.awt.*;
17
import java.awt.image.BufferedImage;
18
import java.io.File;
19
import java.io.IOException;
20
import java.io.InputStream;
21
import java.io.StringReader;
22
import java.net.URI;
23
import java.nio.file.Path;
24
import java.text.NumberFormat;
25
import java.text.ParseException;
26
27
import static com.keenwrite.dom.DocumentParser.transform;
28
import static com.keenwrite.events.StatusEvent.clue;
29
import static com.keenwrite.preview.HighQualityRenderingHints.RENDERING_HINTS;
30
import static java.awt.image.BufferedImage.TYPE_INT_RGB;
31
import static java.text.NumberFormat.getIntegerInstance;
32
import static org.apache.batik.transcoder.SVGAbstractTranscoder.KEY_WIDTH;
33
import static org.apache.batik.transcoder.image.ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER;
34
import static org.apache.batik.util.XMLResourceDescriptor.getXMLParserClassName;
35
36
/**
37
 * Responsible for converting SVG images into rasterized PNG images.
38
 */
39
public final class SvgRasterizer {
40
  /**
41
   * <a href="https://issues.apache.org/jira/browse/BATIK-1112">Bug fix</a>
42
   */
43
  public static final class InkscapeCssParser extends Parser {
44
    public void parseStyleDeclaration( final String source )
45
      throws CSSException, IOException {
46
      super.parseStyleDeclaration(
47
        source.replaceAll( "-inkscape-font-specification:[^;\"]*;", "" )
48
      );
49
    }
50
  }
51
52
  static {
53
    XMLResourceDescriptor.setCSSParserClassName(
54
      InkscapeCssParser.class.getName()
55
    );
56
  }
57
58
  private static final SAXSVGDocumentFactory FACTORY_DOM =
59
    new SAXSVGDocumentFactory( getXMLParserClassName() );
60
61
  private static final NumberFormat INT_FORMAT = getIntegerInstance();
62
63
  public static final BufferedImage BROKEN_IMAGE_PLACEHOLDER;
64
65
  /**
66
   * A FontAwesome camera icon, cleft asunder.
67
   */
68
  public static final String BROKEN_IMAGE_SVG =
69
    "<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www" +
70
      ".w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c" +
71
      ".332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path " +
72
      "d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531" +
73
      ".03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2" +
74
      ".511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-" +
75
      ".367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1" +
76
      ".699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 " +
77
      "2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094" +
78
      ".0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5" +
79
      ".0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-" +
80
      ".195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4" +
81
      ".191406-3.652344.207031-.015625.414063-.015625.617187-.007812l" +
82
      ".933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2" +
83
      ".820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3" +
84
      ".429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3" +
85
      ".429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 " +
86
      ".140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281" +
87
      ".808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2" +
88
      ".472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4" +
89
      ".285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1" +
90
      ".9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-" +
91
      ".667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1" +
92
      ".253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 " +
93
      "0'/></g></svg>";
94
95
  static {
96
    // The width and height cannot be embedded in the SVG above because the
97
    // path element values are relative to the viewBox dimensions.
98
    final int w = 75;
99
    final int h = 75;
100
    BufferedImage image;
101
102
    try {
103
      image = rasterizeString( BROKEN_IMAGE_SVG, w );
104
    } catch( final Exception ex ) {
105
      image = new BufferedImage( w, h, TYPE_INT_RGB );
106
      final var graphics = (Graphics2D) image.getGraphics();
107
      graphics.setRenderingHints( RENDERING_HINTS );
108
109
      // Fall back to a (\) symbol.
110
      graphics.setColor( new Color( 204, 204, 204 ) );
111
      graphics.fillRect( 0, 0, w, h );
112
      graphics.setColor( new Color( 255, 204, 204 ) );
113
      graphics.setStroke( new BasicStroke( 4 ) );
114
      graphics.drawOval( w / 4, h / 4, w / 2, h / 2 );
115
      graphics.drawLine( w / 4 + (int) (w / 4 / Math.PI),
116
                         h / 4 + (int) (w / 4 / Math.PI),
117
                         w / 2 + w / 4 - (int) (w / 4 / Math.PI),
118
                         h / 2 + h / 4 - (int) (w / 4 / Math.PI) );
119
    }
120
121
    BROKEN_IMAGE_PLACEHOLDER = image;
122
  }
123
124
  /**
125
   * Responsible for creating a new {@link ImageRenderer} implementation that
126
   * can render a DOM as an SVG image.
127
   */
128
  private static class BufferedImageTranscoder extends ImageTranscoder {
129
    private BufferedImage mImage;
130
131
    @Override
132
    public BufferedImage createImage( final int w, final int h ) {
133
      return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB );
134
    }
135
136
    @Override
137
    public void writeImage(
138
      final BufferedImage image, final TranscoderOutput output ) {
139
      mImage = image;
140
    }
141
142
    public BufferedImage getImage() {
143
      return mImage;
144
    }
145
146
    @Override
147
    protected ImageRenderer createRenderer() {
148
      final ImageRenderer renderer = super.createRenderer();
149
      final RenderingHints hints = renderer.getRenderingHints();
150
      hints.putAll( RENDERING_HINTS );
151
      renderer.setRenderingHints( hints );
152
153
      return renderer;
154
    }
155
  }
156
157
  /**
158
   * Rasterizes the given SVG input stream into an image at 96 DPI.
159
   *
160
   * @param svg The SVG data to rasterize, must be closed by caller.
161
   * @return The given input stream converted to a rasterized image.
162
   */
163
  public static BufferedImage rasterize( final InputStream svg )
164
    throws TranscoderException {
165
    return rasterize( svg, 96 );
166
  }
167
168
  /**
169
   * Rasterizes the given SVG input stream into an image.
170
   *
171
   * @param svg The SVG data to rasterize, must be closed by caller.
172
   * @param dpi Resolution to use when rasterizing (default is 96 DPI).
173
   * @return The given input stream converted to a rasterized image at the
174
   * given resolution.
175
   */
176
  public static BufferedImage rasterize(
177
    final InputStream svg, final float dpi ) throws TranscoderException {
178
    final var transcoder = new BufferedImageTranscoder();
179
    transcoder.addTranscodingHint(
180
      KEY_PIXEL_UNIT_TO_MILLIMETER, 1f / dpi * 25.4f );
181
    transcoder.transcode( new TranscoderInput( svg ), null );
182
    return transcoder.getImage();
183
  }
184
185
  /**
186
   * Rasterizes the given document into an image.
187
   *
188
   * @param svg   The SVG {@link Document} to rasterize.
189
   * @param width The rasterized image's width (in pixels).
190
   * @return The rasterized image.
191
   */
192
  public static BufferedImage rasterize( final Document svg, final int width )
193
    throws TranscoderException {
194
    final var transcoder = new BufferedImageTranscoder();
195
    transcoder.addTranscodingHint( KEY_WIDTH, (float) width );
196
    transcoder.transcode( new TranscoderInput( svg ), null );
197
    return transcoder.getImage();
198
  }
199
200
  /**
201
   * Rasterizes the given vector graphic file using the width dimension
202
   * specified by the document's width attribute.
203
   *
204
   * @param document The {@link Document} containing a vector graphic.
205
   * @return A rasterized image as an instance of {@link BufferedImage}, or
206
   * {@link #BROKEN_IMAGE_PLACEHOLDER} if the graphic could not be rasterized.
207
   */
208
  public static BufferedImage rasterize( final Document document )
209
    throws ParseException, TranscoderException {
210
    final var root = document.getDocumentElement();
211
    final var width = root.getAttribute( "width" );
212
    return rasterize( document, INT_FORMAT.parse( width ).intValue() );
213
  }
214
215
  /**
216
   * Rasterizes the vector graphic file at the given URI. If any exception
217
   * happens, a broken image icon is returned instead.
218
   *
219
   * @param path  The {@link Path} to a vector graphic file.
220
   * @param width Scale the image to the given width (px); aspect ratio is
221
   *              maintained.
222
   * @return A rasterized image as an instance of {@link BufferedImage}.
223
   */
224
  public static BufferedImage rasterize( final Path path, final int width ) {
225
    return rasterize( path.toUri(), width );
226
  }
227
228
  /**
229
   * Rasterizes the vector graphic file at the given URI. If any exception
230
   * happens, a broken image icon is returned instead.
231
   *
232
   * @param uri   The URI to a vector graphic file, which must include the
233
   *              protocol scheme (such as file:// or https://).
234
   * @param width Scale the image to the given width (px); aspect ratio is
235
   *              maintained.
236
   * @return A rasterized image as an instance of {@link BufferedImage}.
237
   */
238
  public static BufferedImage rasterize( final String uri, final int width ) {
239
    return rasterize( new File( uri ).toURI(), width );
240
  }
241
242
  /**
243
   * Converts an SVG drawing into a rasterized image that can be drawn on
244
   * a graphics context.
245
   *
246
   * @param uri   The path to the image (can be web address).
247
   * @param width Scale the image to the given width (px); aspect ratio is
248
   *              maintained.
249
   * @return The vector graphic transcoded into a raster image format.
250
   */
251
  public static BufferedImage rasterize( final URI uri, final int width ) {
252
    try {
253
      return rasterize( FACTORY_DOM.createDocument( uri.toString() ), width );
254
    } catch( final Exception ex ) {
255
      clue( ex );
256
    }
257
258
    return BROKEN_IMAGE_PLACEHOLDER;
259
  }
260
261
  /**
262
   * Converts an SVG string into a rasterized image that can be drawn on
263
   * a graphics context. The dimensions are determined from the document.
264
   *
265
   * @param xml The SVG xml document.
266
   * @return The vector graphic transcoded into a raster image format.
267
   */
268
  public static BufferedImage rasterizeString( final String xml )
269
    throws ParseException, TranscoderException {
270
    final var document = toDocument( xml );
271
    final var root = document.getDocumentElement();
272
    final var width = root.getAttribute( "width" );
273
    return rasterizeString( xml, INT_FORMAT.parse( width ).intValue() );
274
  }
275
276
  /**
277
   * Converts an SVG string into a rasterized image that can be drawn on
278
   * a graphics context.
279
   *
280
   * @param svg The SVG xml document.
281
   * @param w   Scale the image width to this size (aspect ratio is
282
   *            maintained).
283
   * @return The vector graphic transcoded into a raster image format.
284
   */
285
  public static BufferedImage rasterizeString( final String svg, final int w )
286
    throws TranscoderException {
287
    return rasterize( toDocument( svg ), w );
288
  }
289
290
  /**
291
   * Given a document object model (DOM) {@link Element}, this will convert that
292
   * element to a string.
293
   *
294
   * @param root The DOM node to convert to a string.
295
   * @return The DOM node as an escaped, plain text string.
296
   */
297
  public static String toSvg( final Element root ) {
298
    try {
299
      return transform( root ).replaceAll( "xmlns=\"\" ", "" );
300
    } catch( final Exception ex ) {
301
      clue( ex );
302
    }
303
304
    return BROKEN_IMAGE_SVG;
305
  }
306
307
  /**
308
   * Converts an SVG XML string into a new {@link Document} instance.
309
   *
310
   * @param xml The XML containing SVG elements.
311
   * @return The SVG contents parsed into a {@link Document} object model.
312
   */
313
  private static Document toDocument( final String xml ) {
314
    try( final var reader = new StringReader( xml ) ) {
315
      return FACTORY_DOM.createSVGDocument(
316
        "http://www.w3.org/2000/svg", reader );
317
    } catch( final Exception ex ) {
318
      throw new IllegalArgumentException( ex );
319
    }
5
import org.apache.batik.bridge.BridgeContext;
6
import org.apache.batik.bridge.DocumentLoader;
7
import org.apache.batik.bridge.UserAgent;
8
import org.apache.batik.bridge.UserAgentAdapter;
9
import org.apache.batik.css.parser.Parser;
10
import org.apache.batik.gvt.renderer.ImageRenderer;
11
import org.apache.batik.transcoder.TranscoderException;
12
import org.apache.batik.transcoder.TranscoderInput;
13
import org.apache.batik.transcoder.TranscoderOutput;
14
import org.apache.batik.transcoder.image.ImageTranscoder;
15
import org.apache.batik.util.XMLResourceDescriptor;
16
import org.w3c.css.sac.CSSException;
17
import org.w3c.dom.Document;
18
import org.w3c.dom.Element;
19
20
import java.awt.*;
21
import java.awt.image.BufferedImage;
22
import java.io.File;
23
import java.io.IOException;
24
import java.io.InputStream;
25
import java.io.StringReader;
26
import java.net.URI;
27
import java.nio.file.Path;
28
import java.text.NumberFormat;
29
import java.text.ParseException;
30
31
import static com.keenwrite.dom.DocumentParser.transform;
32
import static com.keenwrite.events.StatusEvent.clue;
33
import static com.keenwrite.preview.HighQualityRenderingHints.RENDERING_HINTS;
34
import static java.awt.image.BufferedImage.TYPE_INT_RGB;
35
import static java.text.NumberFormat.getIntegerInstance;
36
import static org.apache.batik.bridge.UnitProcessor.createContext;
37
import static org.apache.batik.bridge.UnitProcessor.svgHorizontalLengthToUserSpace;
38
import static org.apache.batik.transcoder.SVGAbstractTranscoder.KEY_WIDTH;
39
import static org.apache.batik.transcoder.TranscodingHints.Key;
40
import static org.apache.batik.transcoder.image.ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER;
41
import static org.apache.batik.util.SVGConstants.SVG_WIDTH_ATTRIBUTE;
42
import static org.apache.batik.util.XMLResourceDescriptor.getXMLParserClassName;
43
44
/**
45
 * Responsible for converting SVG images into rasterized PNG images.
46
 */
47
public final class SvgRasterizer {
48
  /**
49
   * <a href="https://issues.apache.org/jira/browse/BATIK-1112">Bug fix</a>
50
   */
51
  public static final class InkscapeCssParser extends Parser {
52
    public void parseStyleDeclaration( final String source )
53
      throws CSSException, IOException {
54
      super.parseStyleDeclaration(
55
        source.replaceAll( "-inkscape-font-specification:[^;\"]*;", "" )
56
      );
57
    }
58
  }
59
60
  static {
61
    XMLResourceDescriptor.setCSSParserClassName(
62
      InkscapeCssParser.class.getName()
63
    );
64
  }
65
66
  private static final UserAgent USER_AGENT = new UserAgentAdapter();
67
  private static final BridgeContext BRIDGE_CONTEXT = new BridgeContext(
68
    USER_AGENT, new DocumentLoader( USER_AGENT )
69
  );
70
71
  private static final SAXSVGDocumentFactory FACTORY_DOM =
72
    new SAXSVGDocumentFactory( getXMLParserClassName() );
73
74
  private static final NumberFormat INT_FORMAT = getIntegerInstance();
75
76
  public static final BufferedImage BROKEN_IMAGE_PLACEHOLDER;
77
78
  /**
79
   * A FontAwesome camera icon, cleft asunder.
80
   */
81
  public static final String BROKEN_IMAGE_SVG =
82
    "<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www" +
83
      ".w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c" +
84
      ".332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path " +
85
      "d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531" +
86
      ".03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2" +
87
      ".511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-" +
88
      ".367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1" +
89
      ".699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 " +
90
      "2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094" +
91
      ".0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5" +
92
      ".0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-" +
93
      ".195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4" +
94
      ".191406-3.652344.207031-.015625.414063-.015625.617187-.007812l" +
95
      ".933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2" +
96
      ".820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3" +
97
      ".429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3" +
98
      ".429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 " +
99
      ".140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281" +
100
      ".808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2" +
101
      ".472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4" +
102
      ".285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1" +
103
      ".9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-" +
104
      ".667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1" +
105
      ".253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 " +
106
      "0'/></g></svg>";
107
108
  static {
109
    // The width and height cannot be embedded in the SVG above because the
110
    // path element values are relative to the viewBox dimensions.
111
    final int w = 75;
112
    final int h = 75;
113
    BufferedImage image;
114
115
    try {
116
      image = rasterizeString( BROKEN_IMAGE_SVG, w );
117
    } catch( final Exception ex ) {
118
      image = new BufferedImage( w, h, TYPE_INT_RGB );
119
      final var graphics = (Graphics2D) image.getGraphics();
120
      graphics.setRenderingHints( RENDERING_HINTS );
121
122
      // Fall back to a (\) symbol.
123
      graphics.setColor( new Color( 204, 204, 204 ) );
124
      graphics.fillRect( 0, 0, w, h );
125
      graphics.setColor( new Color( 255, 204, 204 ) );
126
      graphics.setStroke( new BasicStroke( 4 ) );
127
      graphics.drawOval( w / 4, h / 4, w / 2, h / 2 );
128
      graphics.drawLine( w / 4 + (int) (w / 4 / Math.PI),
129
                         h / 4 + (int) (w / 4 / Math.PI),
130
                         w / 2 + w / 4 - (int) (w / 4 / Math.PI),
131
                         h / 2 + h / 4 - (int) (w / 4 / Math.PI) );
132
    }
133
134
    BROKEN_IMAGE_PLACEHOLDER = image;
135
  }
136
137
  /**
138
   * Responsible for creating a new {@link ImageRenderer} implementation that
139
   * can render a DOM as an SVG image.
140
   */
141
  private static class BufferedImageTranscoder extends ImageTranscoder {
142
    private BufferedImage mImage;
143
144
    @Override
145
    public BufferedImage createImage( final int w, final int h ) {
146
      return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB );
147
    }
148
149
    @Override
150
    public void writeImage(
151
      final BufferedImage image, final TranscoderOutput output ) {
152
      mImage = image;
153
    }
154
155
    public BufferedImage getImage() {
156
      return mImage;
157
    }
158
159
    @Override
160
    protected ImageRenderer createRenderer() {
161
      final ImageRenderer renderer = super.createRenderer();
162
      final RenderingHints hints = renderer.getRenderingHints();
163
      hints.putAll( RENDERING_HINTS );
164
      renderer.setRenderingHints( hints );
165
166
      return renderer;
167
    }
168
  }
169
170
  /**
171
   * Rasterizes the given SVG input stream into an image at 96 DPI.
172
   *
173
   * @param svg The SVG data to rasterize, must be closed by caller.
174
   * @return The given input stream converted to a rasterized image.
175
   */
176
  public static BufferedImage rasterize( final InputStream svg )
177
    throws TranscoderException {
178
    return rasterize( svg, 96 );
179
  }
180
181
  /**
182
   * Rasterizes the given SVG input stream into an image.
183
   *
184
   * @param svg The SVG data to rasterize, must be closed by caller.
185
   * @param dpi Resolution to use when rasterizing (default is 96 DPI).
186
   * @return The given input stream converted to a rasterized image at the
187
   * given resolution.
188
   */
189
  public static BufferedImage rasterize(
190
    final InputStream svg, final float dpi ) throws TranscoderException {
191
    return rasterize(
192
      new TranscoderInput( svg ),
193
      KEY_PIXEL_UNIT_TO_MILLIMETER,
194
      1f / dpi * 25.4f
195
    );
196
  }
197
198
  /**
199
   * Rasterizes the given document into an image.
200
   *
201
   * @param svg   The SVG {@link Document} to rasterize.
202
   * @param width The rasterized image's width (in pixels).
203
   * @return The rasterized image.
204
   */
205
  public static BufferedImage rasterize(
206
    final Document svg, final int width ) throws TranscoderException {
207
    return rasterize(
208
      new TranscoderInput( svg ),
209
      KEY_WIDTH,
210
      fit( svg.getDocumentElement(), width )
211
    );
212
  }
213
214
  /**
215
   * Rasterizes the given vector graphic file using the width dimension
216
   * specified by the document's width attribute.
217
   *
218
   * @param document The {@link Document} containing a vector graphic.
219
   * @return A rasterized image as an instance of {@link BufferedImage}, or
220
   * {@link #BROKEN_IMAGE_PLACEHOLDER} if the graphic could not be rasterized.
221
   */
222
  public static BufferedImage rasterize( final Document document )
223
    throws ParseException, TranscoderException {
224
    final var root = document.getDocumentElement();
225
    final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE );
226
    return rasterize( document, INT_FORMAT.parse( width ).intValue() );
227
  }
228
229
  /**
230
   * Rasterizes the vector graphic file at the given URI. If any exception
231
   * happens, a broken image icon is returned instead.
232
   *
233
   * @param path  The {@link Path} to a vector graphic file.
234
   * @param width Scale the image to the given width (px); aspect ratio is
235
   *              maintained.
236
   * @return A rasterized image as an instance of {@link BufferedImage}.
237
   */
238
  public static BufferedImage rasterize( final Path path, final int width ) {
239
    return rasterize( path.toUri(), width );
240
  }
241
242
  /**
243
   * Rasterizes the vector graphic file at the given URI. If any exception
244
   * happens, a broken image icon is returned instead.
245
   *
246
   * @param uri   The URI to a vector graphic file, which must include the
247
   *              protocol scheme (such as file:// or https://).
248
   * @param width Scale the image to the given width (px); aspect ratio is
249
   *              maintained.
250
   * @return A rasterized image as an instance of {@link BufferedImage}.
251
   */
252
  public static BufferedImage rasterize( final String uri, final int width ) {
253
    return rasterize( new File( uri ).toURI(), width );
254
  }
255
256
  /**
257
   * Converts an SVG drawing into a rasterized image that can be drawn on
258
   * a graphics context.
259
   *
260
   * @param uri   The path to the image (can be web address).
261
   * @param width Scale the image to the given width (px); aspect ratio is
262
   *              maintained.
263
   * @return The vector graphic transcoded into a raster image format.
264
   */
265
  public static BufferedImage rasterize( final URI uri, final int width ) {
266
    try {
267
      return rasterize( FACTORY_DOM.createDocument( uri.toString() ), width );
268
    } catch( final Exception ex ) {
269
      clue( ex );
270
    }
271
272
    return BROKEN_IMAGE_PLACEHOLDER;
273
  }
274
275
  /**
276
   * Converts an SVG string into a rasterized image that can be drawn on
277
   * a graphics context. The dimensions are determined from the document.
278
   *
279
   * @param xml The SVG xml document.
280
   * @return The vector graphic transcoded into a raster image format.
281
   */
282
  public static BufferedImage rasterizeString( final String xml )
283
    throws ParseException, TranscoderException {
284
    final var document = toDocument( xml );
285
    final var root = document.getDocumentElement();
286
    final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE );
287
    return rasterizeString( xml, INT_FORMAT.parse( width ).intValue() );
288
  }
289
290
  /**
291
   * Converts an SVG string into a rasterized image that can be drawn on
292
   * a graphics context.
293
   *
294
   * @param svg The SVG xml document.
295
   * @param w   Scale the image width to this size (aspect ratio is
296
   *            maintained).
297
   * @return The vector graphic transcoded into a raster image format.
298
   */
299
  public static BufferedImage rasterizeString( final String svg, final int w )
300
    throws TranscoderException {
301
    return rasterize( toDocument( svg ), w );
302
  }
303
304
  /**
305
   * Given a document object model (DOM) {@link Element}, this will convert that
306
   * element to a string.
307
   *
308
   * @param root The DOM node to convert to a string.
309
   * @return The DOM node as an escaped, plain text string.
310
   */
311
  public static String toSvg( final Element root ) {
312
    try {
313
      return transform( root ).replaceAll( "xmlns=\"\" ", "" );
314
    } catch( final Exception ex ) {
315
      clue( ex );
316
    }
317
318
    return BROKEN_IMAGE_SVG;
319
  }
320
321
  /**
322
   * Converts an SVG XML string into a new {@link Document} instance.
323
   *
324
   * @param xml The XML containing SVG elements.
325
   * @return The SVG contents parsed into a {@link Document} object model.
326
   */
327
  private static Document toDocument( final String xml ) {
328
    try( final var reader = new StringReader( xml ) ) {
329
      return FACTORY_DOM.createSVGDocument(
330
        "http://www.w3.org/2000/svg", reader );
331
    } catch( final Exception ex ) {
332
      throw new IllegalArgumentException( ex );
333
    }
334
  }
335
336
  /**
337
   * Creates a rasterized image of the given source document.
338
   *
339
   * @param input The source document to transcode.
340
   * @param key   Transcoding hint key.
341
   * @param width Transcoding hint value.
342
   * @return A new {@link BufferedImageTranscoder} instance with the given
343
   * transcoding hint applied.
344
   */
345
  private static BufferedImage rasterize(
346
    final TranscoderInput input, final Key key, final float width )
347
    throws TranscoderException {
348
    final var transcoder = new BufferedImageTranscoder();
349
350
    transcoder.addTranscodingHint( key, width );
351
    transcoder.transcode( input, null );
352
353
    return transcoder.getImage();
354
  }
355
356
  /**
357
   * Returns either the given element's SVG document width, or the display
358
   * width, whichever is smaller.
359
   *
360
   * @param root  The SVG document's root node.
361
   * @param width The display width (e.g., rendering canvas width).
362
   * @return The lower value of the document's width or the display width.
363
   */
364
  private static float fit( final Element root, final int width ) {
365
    final var w = root.getAttribute( SVG_WIDTH_ATTRIBUTE );
366
367
    return w == null || w.isBlank() ? width : fit( root, w, width );
368
  }
369
370
  /**
371
   * Returns the width in user space units (pixels?).
372
   *
373
   * @param root  The element containing the width attribute.
374
   * @param w     The element's width attribute value.
375
   * @param width The rendering canvas width.
376
   * @return Either the rendering canvas width or SVG document width,
377
   * whichever is smaller.
378
   */
379
  private static float fit(
380
    final Element root, final String w, final int width ) {
381
    final var usWidth = svgHorizontalLengthToUserSpace(
382
      w, SVG_WIDTH_ATTRIBUTE, createContext( BRIDGE_CONTEXT, root )
383
    );
384
385
    // If the image is too small, scale it to 1/4 the canvas width.
386
    return Math.min( usWidth < 5 ? width / 4.0f : usWidth, (float) width );
320387
  }
321388
}
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedDivRenderer.java
4444
  }
4545
46
  static class Factory implements @NotNull NodeRendererFactory {
46
  static class Factory implements NodeRendererFactory {
4747
    @Override
4848
    public @NotNull NodeRenderer apply( @NotNull final DataHolder options ) {
M src/main/java/com/keenwrite/ui/fonts/IconFactory.java
2323
import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER;
2424
import static com.keenwrite.preview.SvgRasterizer.rasterize;
25
import static java.awt.Font.BOLD;
25
import static java.awt.Font.*;
2626
import static java.nio.file.Files.readAttributes;
2727
import static javafx.embed.swing.SwingFXUtils.toFXImage;
...
112112
    }
113113
114
    if(extension == null) {
114
    if( extension == null ) {
115115
      extension = "";
116116
    }
...
158158
  }
159159
160
  /**
161
   * Returns the font to use when adding icons to the UI.
162
   *
163
   * @param size The font size to use when drawing the icon.
164
   * @return A font containing numerous icons.
165
   */
160166
  public static Font getIconFont( final int size ) {
161
    return new Font( FONT_AWESOME.getName(), BOLD, size );
167
    try( final var fontStream = openFont() ) {
168
      final var font = createFont( TRUETYPE_FONT, fontStream );
169
      return font.deriveFont( PLAIN, size );
170
    } catch( final Exception e ) {
171
      // This doesn't actually work, seemingly after an upgrade to ControlsFX.
172
      // As such, creating the font and deriving it will work.
173
      return new Font( FONT_AWESOME.getName(), PLAIN, size );
174
    }
175
  }
176
177
  /**
178
   * This re-reads the {@link FontAwesome} font TTF resource. For a reason
179
   * not yet investigated, the font doesn't appear to be accessible to the
180
   * application. This may have happened during an upgrade to ControlsFX.
181
   * Callers are responsible for closing the stream.
182
   *
183
   * @return A stream containing font TrueType glyph information.
184
   */
185
  private static InputStream openFont() {
186
    return FontAwesome.class.getResourceAsStream( "fontawesome-webfont.ttf" );
162187
  }
163188
M src/main/java/com/keenwrite/ui/heuristics/DocumentStatistics.java
44
import com.keenwrite.events.DocumentChangedEvent;
55
import com.keenwrite.preferences.Workspace;
6
import com.keenwrite.preview.HtmlPanel;
76
import com.whitemagicsoftware.wordcount.TokenizerException;
87
import javafx.beans.property.IntegerProperty;
...
6867
   * Called when the hash code for the current document changes. This happens
6968
   * when non-collapsable-whitespace is added to the document. When the
70
   * document is sent to {@link HtmlPanel} for rendering, the parsed document
71
   * is converted to text. If that text differs in its hash code, then this
72
   * method is called. The implication is that all variables and executable
73
   * statements have been replaced. An event bus subscriber is used so that
74
   * text processing occurs outside of the UI processing threads.
69
   * document is sent for rendering, the parsed document is converted to text.
70
   * If that text differs in its hash code, then this method is called. The
71
   * implication is that all variables and executable statements have been
72
   * replaced. An event bus subscriber is used so that text processing occurs
73
   * outside the UI processing threads.
7574
   *
7675
   * @param event Container for the document text that has changed.
M src/main/java/com/keenwrite/ui/logging/LogView.java
4141
  /**
4242
   * Number of error messages to retain in the {@link TableView}; must be
43
   * greater than zero.
43
   * greater than zero. Typesetting the document can cause many page number
44
   * messages to be logged.
4445
   */
45
  private static final int CACHE_SIZE = 150;
46
  private static final int CACHE_SIZE = 500;
4647
4748
  private final ObservableList<LogEntry> mItems = observableArrayList();
A src/main/java/com/keenwrite/util/ArrayScanner.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.util;
3
4
import static java.lang.Math.max;
5
6
/**
7
 * Scans an array (haystack) for a particular value (needle).
8
 *
9
 * <p>
10
 * This class is {@code null}-hostile.
11
 */
12
public class ArrayScanner {
13
14
  /**
15
   * The index value returned when an element is not found in an array.
16
   */
17
  public static final int MISSING = -1;
18
19
  /**
20
   * Finds the index of the given needle in the haystack.
21
   *
22
   * @param haystack The haystack to search through for the needle.
23
   * @param needle   The needle to find in the haystack.
24
   * @return Index of the needle within the haystack, or {@link #MISSING}
25
   * if not found.
26
   */
27
  public static int indexOf( final Object[] haystack, final Object needle ) {
28
    assert haystack != null;
29
    assert needle != null;
30
31
    return indexOf( haystack, needle, 0 );
32
  }
33
34
  /**
35
   * Finds the index of the given needle in the haystack.
36
   *
37
   * @param haystack The haystack to search through for the needle.
38
   * @param needle   The needle to find in the haystack.
39
   * @param offset   The starting offset into the haystack to begin looking
40
   *                 (the value may be greater than or less than the number
41
   *                 of array elements).
42
   * @return Index of the needle within the haystack, or {@link #MISSING}
43
   * if not found.
44
   */
45
  public static int indexOf(
46
    final Object[] haystack, final Object needle, int offset ) {
47
    assert haystack != null;
48
    assert needle != null;
49
50
    for( int i = max( 0, offset ); i < haystack.length; i++ ) {
51
      if( needle.equals( haystack[ i ] ) ) {
52
        return i;
53
      }
54
    }
55
56
    return MISSING;
57
  }
58
59
  /**
60
   * Checks if the object is in the given array.
61
   *
62
   * @param haystack The haystack to search through for the needle.
63
   * @param needle   The needle to find in the haystack.
64
   * @return {@code true} if the array contains the object.
65
   */
66
  public static boolean contains(
67
    final Object[] haystack, final Object needle ) {
68
    assert haystack != null;
69
    assert needle != null;
70
71
    return indexOf( haystack, needle ) != MISSING;
72
  }
73
}
174
M src/main/java/com/keenwrite/util/FontLoader.java
22
package com.keenwrite.util;
33
4
import com.keenwrite.preview.HtmlPreview;
5
64
import java.awt.*;
75
import java.awt.font.TextAttribute;
...
2220
2321
/**
24
 * Responsible for loading fonts into the application's
25
 * {@link GraphicsEnvironment} so that the {@link HtmlPreview} can display
26
 * the text using a non-system font.
22
 * Loads fonts into the application's {@link GraphicsEnvironment} so that
23
 * preview can display text using non-system fonts.
2724
 */
2825
public final class FontLoader {
M src/main/resources/com/keenwrite/preview/webview.css
122122
/* PREFORMATTED CODE ***/
123123
pre, code, tt {
124
  /* Must be bundled in JAR file. */
125124
  font-family: 'Source Code Pro';
126
  font-size: 10pt;
125
  font-size: 13px;
127126
  background-color: #f8f8f8;
128127
  text-decoration: none;