Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M README.md
6161
* Support for Pandoc's fenced div extended attribute syntax
6262
* R integration
63
* XML transformation using XSLT3 or older
6463
* Customizable user interface having detachable tabs
6564
* Platform-independent (Windows, Linux, MacOS)
M README.zh-CN.md
4545
* 带变量替换的实时预览
4646
* 基于变量值自动完成变量名
47
* 使用XSLT3或更早版本的XML文档转换
4847
* 独立于操作系统
4948
* 打字时拼写检查
M build.gradle
8383
  implementation 'org.yaml:snakeyaml:1.27'
8484
85
  // XML and XSL
85
  // XML
8686
  implementation 'com.ximpleware:vtd-xml:2.13.4'
87
  implementation 'net.sf.saxon:Saxon-HE:10.3'
8887
8988
  // HTML parsing and rendering
M docs/credits.md
1010
* Alex Bertram, [Renjin](https://www.renjin.org/)
1111
* Vladimir Schneider: [flexmark](https://github.com/vsch/flexmark-java)
12
* Michael Kay, [XSLT Processor](http://www.saxonica.com/)
1312
* Shy Shalom, Kohei Taketa: [juniversalchardet](https://github.com/takscape/juniversalchardet)
1413
D docs/licenses/SAXON-HE.txt
1
                             Mozilla Public License
2
                                  Version 2.0
3
4
1. Definitions
5
6
   1.1. “Contributor”
7
          means each individual or legal entity that creates, contributes
8
          to the creation of, or owns Covered Software.
9
10
   1.2. “Contributor Version”
11
          means the combination of the Contributions of others (if any)
12
          used by a Contributor and that particular Contributor’s
13
          Contribution.
14
15
   1.3. “Contribution”
16
          means Covered Software of a particular Contributor.
17
18
   1.4. “Covered Software”
19
          means Source Code Form to which the initial Contributor has
20
          attached the notice in Exhibit A, the Executable Form of such
21
          Source Code Form, and Modifications of such Source Code Form, in
22
          each case including portions thereof.
23
24
   1.5. “Incompatible With Secondary Licenses”
25
          means
26
27
         a. that the initial Contributor has attached the notice described
28
            in Exhibit B to the Covered Software; or
29
         b. that the Covered Software was made available under the terms
30
            of version 1.1 or earlier of the License, but not also under
31
            the terms of a Secondary License.
32
33
   1.6. “Executable Form”
34
          means any form of the work other than Source Code Form.
35
36
   1.7. “Larger Work”
37
          means a work that combines Covered Software with other material,
38
          in a separate file or files, that is not Covered Software.
39
40
   1.8. “License”
41
          means this document.
42
43
   1.9. “Licensable”
44
          means having the right to grant, to the maximum extent possible,
45
          whether at the time of the initial grant or subsequently, any
46
          and all of the rights conveyed by this License.
47
48
   1.10. “Modifications”
49
          means any of the following:
50
51
         a. any file in Source Code Form that results from an addition to,
52
            deletion from, or modification of the contents of Covered
53
            Software; or
54
         b. any new file in Source Code Form that contains any Covered
55
            Software.
56
57
   1.11. “Patent Claims” of a Contributor
58
          means any patent claim(s), including without limitation, method,
59
          process, and apparatus claims, in any patent Licensable by such
60
          Contributor that would be infringed, but for the grant of the
61
          License, by the making, using, selling, offering for sale,
62
          having made, import, or transfer of either its Contributions or
63
          its Contributor Version.
64
65
   1.12. “Secondary License”
66
          means either the GNU General Public License, Version 2.0, the
67
          GNU Lesser General Public License, Version 2.1, the GNU Affero
68
          General Public License, Version 3.0, or any later versions of
69
          those licenses.
70
71
   1.13. “Source Code Form”
72
          means the form of the work preferred for making modifications.
73
74
   1.14. “You” (or “Your”)
75
          means an individual or a legal entity exercising rights under
76
          this License. For legal entities, “You” includes any entity that
77
          controls, is controlled by, or is under common control with You.
78
          For purposes of this definition, “control” means (a) the power,
79
          direct or indirect, to cause the direction or management of such
80
          entity, whether by contract or otherwise, or (b) ownership of
81
          more than fifty percent (50%) of the outstanding shares or
82
          beneficial ownership of such entity.
83
84
2. License Grants and Conditions
85
86
  2.1. Grants
87
88
   Each Contributor hereby grants You a world-wide, royalty-free,
89
   non-exclusive license:
90
    a. under intellectual property rights (other than patent or trademark)
91
       Licensable by such Contributor to use, reproduce, make available,
92
       modify, display, perform, distribute, and otherwise exploit its
93
       Contributions, either on an unmodified basis, with Modifications,
94
       or as part of a Larger Work; and
95
    b. under Patent Claims of such Contributor to make, use, sell, offer
96
       for sale, have made, import, and otherwise transfer either its
97
       Contributions or its Contributor Version.
98
99
  2.2. Effective Date
100
101
   The licenses granted in Section 2.1 with respect to any Contribution
102
   become effective for each Contribution on the date the Contributor
103
   first distributes such Contribution.
104
105
  2.3. Limitations on Grant Scope
106
107
   The licenses granted in this Section 2 are the only rights granted
108
   under this License. No additional rights or licenses will be implied
109
   from the distribution or licensing of Covered Software under this
110
   License. Notwithstanding Section 2.1(b) above, no patent license is
111
   granted by a Contributor:
112
    a. for any code that a Contributor has removed from Covered Software;
113
       or
114
    b. for infringements caused by: (i) Your and any other third party’s
115
       modifications of Covered Software, or (ii) the combination of its
116
       Contributions with other software (except as part of its
117
       Contributor Version); or
118
    c. under Patent Claims infringed by Covered Software in the absence of
119
       its Contributions.
120
121
   This License does not grant any rights in the trademarks, service
122
   marks, or logos of any Contributor (except as may be necessary to
123
   comply with the notice requirements in Section 3.4).
124
125
  2.4. Subsequent Licenses
126
127
   No Contributor makes additional grants as a result of Your choice to
128
   distribute the Covered Software under a subsequent version of this
129
   License (see Section 10.2) or under the terms of a Secondary License
130
   (if permitted under the terms of Section 3.3).
131
132
  2.5. Representation
133
134
   Each Contributor represents that the Contributor believes its
135
   Contributions are its original creation(s) or it has sufficient rights
136
   to grant the rights to its Contributions conveyed by this License.
137
138
  2.6. Fair Use
139
140
   This License is not intended to limit any rights You have under
141
   applicable copyright doctrines of fair use, fair dealing, or other
142
   equivalents.
143
144
  2.7. Conditions
145
146
   Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
147
   in Section 2.1.
148
149
3. Responsibilities
150
151
  3.1. Distribution of Source Form
152
153
   All distribution of Covered Software in Source Code Form, including any
154
   Modifications that You create or to which You contribute, must be under
155
   the terms of this License. You must inform recipients that the Source
156
   Code Form of the Covered Software is governed by the terms of this
157
   License, and how they can obtain a copy of this License. You may not
158
   attempt to alter or restrict the recipients’ rights in the Source Code
159
   Form.
160
161
  3.2. Distribution of Executable Form
162
163
   If You distribute Covered Software in Executable Form then:
164
    a. such Covered Software must also be made available in Source Code
165
       Form, as described in Section 3.1, and You must inform recipients
166
       of the Executable Form how they can obtain a copy of such Source
167
       Code Form by reasonable means in a timely manner, at a charge no
168
       more than the cost of distribution to the recipient; and
169
    b. You may distribute such Executable Form under the terms of this
170
       License, or sublicense it under different terms, provided that the
171
       license for the Executable Form does not attempt to limit or alter
172
       the recipients’ rights in the Source Code Form under this License.
173
174
  3.3. Distribution of a Larger Work
175
176
   You may create and distribute a Larger Work under terms of Your choice,
177
   provided that You also comply with the requirements of this License for
178
   the Covered Software. If the Larger Work is a combination of Covered
179
   Software with a work governed by one or more Secondary Licenses, and
180
   the Covered Software is not Incompatible With Secondary Licenses, this
181
   License permits You to additionally distribute such Covered Software
182
   under the terms of such Secondary License(s), so that the recipient of
183
   the Larger Work may, at their option, further distribute the Covered
184
   Software under the terms of either this License or such Secondary
185
   License(s).
186
187
  3.4. Notices
188
189
   You may not remove or alter the substance of any license notices
190
   (including copyright notices, patent notices, disclaimers of warranty,
191
   or limitations of liability) contained within the Source Code Form of
192
   the Covered Software, except that You may alter any license notices to
193
   the extent required to remedy known factual inaccuracies.
194
195
  3.5. Application of Additional Terms
196
197
   You may choose to offer, and to charge a fee for, warranty, support,
198
   indemnity or liability obligations to one or more recipients of Covered
199
   Software. However, You may do so only on Your own behalf, and not on
200
   behalf of any Contributor. You must make it absolutely clear that any
201
   such warranty, support, indemnity, or liability obligation is offered
202
   by You alone, and You hereby agree to indemnify every Contributor for
203
   any liability incurred by such Contributor as a result of warranty,
204
   support, indemnity or liability terms You offer. You may include
205
   additional disclaimers of warranty and limitations of liability
206
   specific to any jurisdiction.
207
208
4. Inability to Comply Due to Statute or Regulation
209
210
   If it is impossible for You to comply with any of the terms of this
211
   License with respect to some or all of the Covered Software due to
212
   statute, judicial order, or regulation then You must: (a) comply with
213
   the terms of this License to the maximum extent possible; and (b)
214
   describe the limitations and the code they affect. Such description
215
   must be placed in a text file included with all distributions of the
216
   Covered Software under this License. Except to the extent prohibited by
217
   statute or regulation, such description must be sufficiently detailed
218
   for a recipient of ordinary skill to be able to understand it.
219
220
5. Termination
221
222
   5.1. The rights granted under this License will terminate automatically
223
   if You fail to comply with any of its terms. However, if You become
224
   compliant, then the rights granted under this License from a particular
225
   Contributor are reinstated (a) provisionally, unless and until such
226
   Contributor explicitly and finally terminates Your grants, and (b) on
227
   an ongoing basis, if such Contributor fails to notify You of the
228
   non-compliance by some reasonable means prior to 60 days after You have
229
   come back into compliance. Moreover, Your grants from a particular
230
   Contributor are reinstated on an ongoing basis if such Contributor
231
   notifies You of the non-compliance by some reasonable means, this is
232
   the first time You have received notice of non-compliance with this
233
   License from such Contributor, and You become compliant prior to 30
234
   days after Your receipt of the notice.
235
236
   5.2. If You initiate litigation against any entity by asserting a
237
   patent infringement claim (excluding declaratory judgment actions,
238
   counter-claims, and cross-claims) alleging that a Contributor Version
239
   directly or indirectly infringes any patent, then the rights granted to
240
   You by any and all Contributors for the Covered Software under
241
   Section 2.1 of this License shall terminate.
242
243
   5.3. In the event of termination under Sections 5.1 or 5.2 above, all
244
   end user license agreements (excluding distributors and resellers)
245
   which have been validly granted by You or Your distributors under this
246
   License prior to termination shall survive termination.
247
248
6. Disclaimer of Warranty
249
250
   Covered Software is provided under this License on an “as is” basis,
251
   without warranty of any kind, either expressed, implied, or statutory,
252
   including, without limitation, warranties that the Covered Software is
253
   free of defects, merchantable, fit for a particular purpose or
254
   non-infringing. The entire risk as to the quality and performance of
255
   the Covered Software is with You. Should any Covered Software prove
256
   defective in any respect, You (not any Contributor) assume the cost of
257
   any necessary servicing, repair, or correction. This disclaimer of
258
   warranty constitutes an essential part of this License. No use of any
259
   Covered Software is authorized under this License except under this
260
   disclaimer.
261
262
7. Limitation of Liability
263
264
   Under no circumstances and under no legal theory, whether tort
265
   (including negligence), contract, or otherwise, shall any Contributor,
266
   or anyone who distributes Covered Software as permitted above, be
267
   liable to You for any direct, indirect, special, incidental, or
268
   consequential damages of any character including, without limitation,
269
   damages for lost profits, loss of goodwill, work stoppage, computer
270
   failure or malfunction, or any and all other commercial damages or
271
   losses, even if such party shall have been informed of the possibility
272
   of such damages. This limitation of liability shall not apply to
273
   liability for death or personal injury resulting from such party’s
274
   negligence to the extent applicable law prohibits such limitation. Some
275
   jurisdictions do not allow the exclusion or limitation of incidental or
276
   consequential damages, so this exclusion and limitation may not apply
277
   to You.
278
279
8. Litigation
280
281
   Any litigation relating to this License may be brought only in the
282
   courts of a jurisdiction where the defendant maintains its principal
283
   place of business and such litigation shall be governed by laws of that
284
   jurisdiction, without reference to its conflict-of-law provisions.
285
   Nothing in this Section shall prevent a party’s ability to bring
286
   cross-claims or counter-claims.
287
288
9. Miscellaneous
289
290
   This License represents the complete agreement concerning the subject
291
   matter hereof. If any provision of this License is held to be
292
   unenforceable, such provision shall be reformed only to the extent
293
   necessary to make it enforceable. Any law or regulation which provides
294
   that the language of a contract shall be construed against the drafter
295
   shall not be used to construe this License against a Contributor.
296
297
10. Versions of the License
298
299
  10.1. New Versions
300
301
   Mozilla Foundation is the license steward. Except as provided in
302
   Section 10.3, no one other than the license steward has the right to
303
   modify or publish new versions of this License. Each version will be
304
   given a distinguishing version number.
305
306
  10.2. Effect of New Versions
307
308
   You may distribute the Covered Software under the terms of the version
309
   of the License under which You originally received the Covered
310
   Software, or under the terms of any subsequent version published by the
311
   license steward.
312
313
  10.3. Modified Versions
314
315
   If you create software not governed by this License, and you want to
316
   create a new license for such software, you may create and use a
317
   modified version of this License if you rename the license and remove
318
   any references to the name of the license steward (except to note that
319
   such modified license differs from this License).
320
321
  10.4. Distributing Source Code Form that is Incompatible With Secondary
322
  Licenses
323
324
   If You choose to distribute Source Code Form that is Incompatible With
325
   Secondary Licenses under the terms of this version of the License, the
326
   notice described in Exhibit B of this License must be attached.
327
328
Exhibit A - Source Code Form License Notice
329
330
     This Source Code Form is subject to the terms of the Mozilla Public
331
     License, v. 2.0. If a copy of the MPL was not distributed with this
332
     file, You can obtain one at https://mozilla.org/MPL/2.0/.
333
334
   If it is not possible or desirable to put the notice in a particular
335
   file, then You may include the notice in a location (such as a LICENSE
336
   file in a relevant directory) where a recipient would be likely to look
337
   for such a notice.
338
339
   You may add additional accurate notices of copyright ownership.
340
341
Exhibit B - “Incompatible With Secondary Licenses” Notice
342
343
     This Source Code Form is “Incompatible With Secondary Licenses”, as
344
     defined by the Mozilla Public License, v. 2.0.
3451
M installer.sh
143143
readonly SCRIPT_SRC="\$(dirname "\${BASH_SOURCE[\${#BASH_SOURCE[@]} - 1]}")"
144144
145
"\${SCRIPT_SRC}/${ARG_JAVA_DIR}/bin/java" ${OPT_JAVA} -jar "\${SCRIPT_SRC}/${FILE_APP_JAR}" "\$@" 2>&1 >/dev/null &
145
"\${SCRIPT_SRC}/${ARG_JAVA_DIR}/bin/java" ${OPT_JAVA} -jar "\${SCRIPT_SRC}/${FILE_APP_JAR}" "\$@" 2>/dev/null &
146146
__EOT
147147
M src/main/java/com/keenwrite/AbstractFileFactory.java
2121
   * called when it is known that the file type won't be a definition file
2222
   * (e.g., YAML or other definition source), but rather an editable file
23
   * (e.g., Markdown, XML, etc.).
23
   * (e.g., Markdown, R Markdown, etc.).
2424
   *
2525
   * @param path The path with a file name extension.
M src/main/java/com/keenwrite/MainPane.java
9595
   */
9696
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
97
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED
98
  );
99
100
  /**
101
   * Prevents re-instantiation of processing classes.
102
   */
103
  private final Map<TextResource, Processor<String>> mProcessors =
104
    new HashMap<>();
105
106
  private final Workspace mWorkspace;
107
108
  /**
109
   * Groups similar file type tabs together.
110
   */
111
  private final Map<MediaType, TabPane> mTabPanes = new HashMap<>();
112
113
  /**
114
   * Stores definition names and values.
115
   */
116
  private final Map<String, String> mResolvedMap =
117
    new HashMap<>( MAP_SIZE_DEFAULT );
118
119
  /**
120
   * Renders the actively selected plain text editor tab.
121
   */
122
  private final HtmlPreview mPreview;
123
124
  /**
125
   * Provides an interactive document outline.
126
   */
127
  private final DocumentOutline mOutline = new DocumentOutline();
128
129
  /**
130
   * Changing the active editor fires the value changed event. This allows
131
   * refreshes to happen when external definitions are modified and need to
132
   * trigger the processing chain.
133
   */
134
  private final ObjectProperty<TextEditor> mActiveTextEditor =
135
    createActiveTextEditor();
136
137
  /**
138
   * Changing the active definition editor fires the value changed event. This
139
   * allows refreshes to happen when external definitions are modified and need
140
   * to trigger the processing chain.
141
   */
142
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
143
    createActiveDefinitionEditor( mActiveTextEditor );
144
145
  /**
146
   * Tracks the number of detached tab panels opened into their own windows,
147
   * which allows unique identification of subordinate windows by their title.
148
   * It is doubtful more than 128 windows, much less 256, will be created.
149
   */
150
  private byte mWindowCount;
151
152
  /**
153
   * Called when the definition data is changed.
154
   */
155
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
156
    event -> {
157
      final var editor = mActiveDefinitionEditor.get();
158
159
      resolve( editor );
160
      process( getActiveTextEditor() );
161
      save( editor );
162
    };
163
164
  private final DocumentStatistics mStatistics;
165
166
  /**
167
   * Adds all content panels to the main user interface. This will load the
168
   * configuration settings from the workspace to reproduce the settings from
169
   * a previous session.
170
   */
171
  public MainPane( final Workspace workspace ) {
172
    mWorkspace = workspace;
173
    mPreview = new HtmlPreview( workspace );
174
    mStatistics = new DocumentStatistics( workspace );
175
    mActiveTextEditor.set( new MarkdownEditor( workspace ) );
176
177
    open( bin( getRecentFiles() ) );
178
    viewPreview();
179
    setDividerPositions( calculateDividerPositions() );
180
181
    // Once the main scene's window regains focus, update the active definition
182
    // editor to the currently selected tab.
183
    runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
184
      // Order matters here. We want to close all the tabs to ensure each
185
      // is saved, but after they are closed, the workspace should still
186
      // retain the list of files that were open. If this line came after
187
      // closing, then restarting the application would list no files.
188
      mWorkspace.save();
189
190
      if( closeAll() ) {
191
        Platform.exit();
192
        System.exit( 0 );
193
      }
194
      else {
195
        event.consume();
196
      }
197
    } ) );
198
199
    register( this );
200
  }
201
202
  @Subscribe
203
  public void handle( final TextEditorFocusEvent event ) {
204
    mActiveTextEditor.set( event.get() );
205
  }
206
207
  @Subscribe
208
  public void handle( final TextDefinitionFocusEvent event ) {
209
    mActiveDefinitionEditor.set( event.get() );
210
  }
211
212
  /**
213
   * Typically called when a file name is clicked in the {@link HtmlPanel}.
214
   *
215
   * @param event The event to process, must contain a valid file reference.
216
   */
217
  @Subscribe
218
  public void handle( final FileOpenEvent event ) {
219
    final File eventFile;
220
    final var eventUri = event.getUri();
221
222
    if( eventUri.isAbsolute() ) {
223
      eventFile = new File( eventUri.getPath() );
224
    }
225
    else {
226
      final var activeFile = getActiveTextEditor().getFile();
227
      final var parent = activeFile.getParentFile();
228
229
      if( parent == null ) {
230
        clue( new FileNotFoundException( eventUri.getPath() ) );
231
        return;
232
      }
233
      else {
234
        final var parentPath = parent.getAbsolutePath();
235
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
236
      }
237
    }
238
239
    runLater( () -> open( eventFile ) );
240
  }
241
242
  @Subscribe
243
  public void handle( final CaretNavigationEvent event ) {
244
    runLater( () -> {
245
      final var textArea = getActiveTextEditor().getTextArea();
246
      textArea.moveTo( event.getOffset() );
247
      textArea.requestFollowCaret();
248
      textArea.requestFocus();
249
    } );
250
  }
251
252
  @Subscribe
253
  @SuppressWarnings( "unused" )
254
  public void handle( final ExportFailedEvent event ) {
255
    final var os = getProperty( "os.name" );
256
    final var arch = getProperty( "os.arch" ).toLowerCase();
257
    final var bits = getProperty( "sun.arch.data.model" );
258
259
    final var title = Messages.get( "Alert.typesetter.missing.title" );
260
    final var header = Messages.get( "Alert.typesetter.missing.header" );
261
    final var version = Messages.get(
262
      "Alert.typesetter.missing.version",
263
      os,
264
      arch
265
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
266
        .replaceAll( "mips.*", "MIPS" )
267
        .replaceAll( "armv.*", "ARM" ),
268
      bits );
269
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
270
271
    // Download and install ConTeXt for {0} {1} {2}-bit
272
    final var content = format( "%s %s", text, version );
273
    final var flowPane = new FlowPane();
274
    final var link = new Hyperlink( text );
275
    final var label = new Label( version );
276
    flowPane.getChildren().addAll( link, label );
277
278
    final var alert = new Alert( ERROR, content, OK );
279
    alert.setTitle( title );
280
    alert.setHeaderText( header );
281
    alert.getDialogPane().contentProperty().set( flowPane );
282
    alert.setGraphic( ICON_DIALOG_NODE );
283
284
    link.setOnAction( ( e ) -> {
285
      alert.close();
286
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
287
      runLater( () -> fireHyperlinkOpenEvent( url ) );
288
    } );
289
290
    alert.showAndWait();
291
  }
292
293
  /**
294
   * TODO: Load divider positions from exported settings, see bin() comment.
295
   */
296
  private double[] calculateDividerPositions() {
297
    final var ratio = 100f / getItems().size() / 100;
298
    final var positions = getDividerPositions();
299
300
    for( int i = 0; i < positions.length; i++ ) {
301
      positions[ i ] = ratio * i;
302
    }
303
304
    return positions;
305
  }
306
307
  /**
308
   * Opens all the files into the application, provided the paths are unique.
309
   * This may only be called for any type of files that a user can edit
310
   * (i.e., update and persist), such as definitions and text files.
311
   *
312
   * @param files The list of files to open.
313
   */
314
  public void open( final List<File> files ) {
315
    files.forEach( this::open );
316
  }
317
318
  /**
319
   * This opens the given file. Since the preview pane is not a file that
320
   * can be opened, it is safe to add a listener to the detachable pane.
321
   *
322
   * @param file The file to open.
323
   */
324
  private void open( final File file ) {
325
    final var tab = createTab( file );
326
    final var node = tab.getContent();
327
    final var mediaType = MediaType.valueFrom( file );
328
    final var tabPane = obtainTabPane( mediaType );
329
330
    tab.setTooltip( createTooltip( file ) );
331
    tabPane.setFocusTraversable( false );
332
    tabPane.setTabClosingPolicy( ALL_TABS );
333
    tabPane.getTabs().add( tab );
334
335
    // Attach the tab scene factory for new tab panes.
336
    if( !getItems().contains( tabPane ) ) {
337
      addTabPane(
338
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
339
      );
340
    }
341
342
    getRecentFiles().add( file.getAbsolutePath() );
343
  }
344
345
  /**
346
   * Opens a new text editor document using the default document file name.
347
   */
348
  public void newTextEditor() {
349
    open( DOCUMENT_DEFAULT );
350
  }
351
352
  /**
353
   * Opens a new definition editor document using the default definition
354
   * file name.
355
   */
356
  public void newDefinitionEditor() {
357
    open( DEFINITION_DEFAULT );
358
  }
359
360
  /**
361
   * Iterates over all tab panes to find all {@link TextEditor}s and request
362
   * that they save themselves.
363
   */
364
  public void saveAll() {
365
    mTabPanes.forEach(
366
      ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
367
        final var node = tab.getContent();
368
        if( node instanceof TextEditor ) {
369
          save( ((TextEditor) node) );
370
        }
371
      } )
372
    );
373
  }
374
375
  /**
376
   * Requests that the active {@link TextEditor} saves itself. Don't bother
377
   * checking if modified first because if the user swaps external media from
378
   * an external source (e.g., USB thumb drive), save should not second-guess
379
   * the user: save always re-saves. Also, it's less code.
380
   */
381
  public void save() {
382
    save( getActiveTextEditor() );
383
  }
384
385
  /**
386
   * Saves the active {@link TextEditor} under a new name.
387
   *
388
   * @param files The new active editor {@link File} reference, must contain
389
   *              at least one element.
390
   */
391
  public void saveAs( final List<File> files ) {
392
    assert files != null;
393
    assert !files.isEmpty();
394
    final var editor = getActiveTextEditor();
395
    final var tab = getTab( editor );
396
    final var file = files.get( 0 );
397
398
    editor.rename( file );
399
    tab.ifPresent( t -> {
400
      t.setText( editor.getFilename() );
401
      t.setTooltip( createTooltip( file ) );
402
    } );
403
404
    save();
405
  }
406
407
  /**
408
   * Saves the given {@link TextResource} to a file. This is typically used
409
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
410
   *
411
   * @param resource The resource to export.
412
   */
413
  private void save( final TextResource resource ) {
414
    try {
415
      resource.save();
416
    } catch( final Exception ex ) {
417
      clue( ex );
418
      sNotifier.alert(
419
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
420
      );
421
    }
422
  }
423
424
  /**
425
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
426
   *
427
   * @return {@code true} when all editors, modified or otherwise, were
428
   * permitted to close; {@code false} when one or more editors were modified
429
   * and the user requested no closing.
430
   */
431
  public boolean closeAll() {
432
    var closable = true;
433
434
    for( final var entry : mTabPanes.entrySet() ) {
435
      final var tabPane = entry.getValue();
436
      final var tabIterator = tabPane.getTabs().iterator();
437
438
      while( tabIterator.hasNext() ) {
439
        final var tab = tabIterator.next();
440
        final var resource = tab.getContent();
441
442
        // The definition panes auto-save, so being specific here prevents
443
        // closing the definitions in the situation where the user wants to
444
        // continue editing (i.e., possibly save unsaved work).
445
        if( !(resource instanceof TextEditor) ) {
446
          continue;
447
        }
448
449
        if( canClose( (TextEditor) resource ) ) {
450
          tabIterator.remove();
451
          close( tab );
452
        }
453
        else {
454
          closable = false;
455
        }
456
      }
457
    }
458
459
    return closable;
460
  }
461
462
  /**
463
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
464
   * event.
465
   *
466
   * @param tab The {@link Tab} that was closed.
467
   */
468
  private void close( final Tab tab ) {
469
    final var handler = tab.getOnClosed();
470
471
    if( handler != null ) {
472
      handler.handle( new ActionEvent() );
473
    }
474
  }
475
476
  /**
477
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
478
   */
479
  public void close() {
480
    final var editor = getActiveTextEditor();
481
482
    if( canClose( editor ) ) {
483
      close( editor );
484
    }
485
  }
486
487
  /**
488
   * Closes the given {@link TextResource}. This must not be called from within
489
   * a loop that iterates over the tab panes using {@code forEach}, lest a
490
   * concurrent modification exception be thrown.
491
   *
492
   * @param resource The {@link TextResource} to close, without confirming with
493
   *                 the user.
494
   */
495
  private void close( final TextResource resource ) {
496
    getTab( resource ).ifPresent(
497
      ( tab ) -> {
498
        tab.getTabPane().getTabs().remove( tab );
499
        close( tab );
500
      }
501
    );
502
  }
503
504
  /**
505
   * Answers whether the given {@link TextResource} may be closed.
506
   *
507
   * @param editor The {@link TextResource} to try closing.
508
   * @return {@code true} when the editor may be closed; {@code false} when
509
   * the user has requested to keep the editor open.
510
   */
511
  private boolean canClose( final TextResource editor ) {
512
    final var editorTab = getTab( editor );
513
    final var canClose = new AtomicBoolean( true );
514
515
    if( editor.isModified() ) {
516
      final var filename = new StringBuilder();
517
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
518
519
      final var message = sNotifier.createNotification(
520
        Messages.get( "Alert.file.close.title" ),
521
        Messages.get( "Alert.file.close.text" ),
522
        filename.toString()
523
      );
524
525
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
526
527
      dialog.showAndWait().ifPresent(
528
        save -> canClose.set( save == YES ? editor.save() : save == NO )
529
      );
530
    }
531
532
    return canClose.get();
533
  }
534
535
  private ObjectProperty<TextEditor> createActiveTextEditor() {
536
    final var editor = new SimpleObjectProperty<TextEditor>();
537
538
    editor.addListener( ( c, o, n ) -> {
539
      if( n != null ) {
540
        mPreview.setBaseUri( n.getPath() );
541
        process( n );
542
      }
543
    } );
544
545
    return editor;
546
  }
547
548
  /**
549
   * Adds the HTML preview tab to its own, singular tab pane.
550
   */
551
  public void viewPreview() {
552
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
553
  }
554
555
  /**
556
   * Adds the document outline tab to its own, singular tab pane.
557
   */
558
  public void viewOutline() {
559
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
560
  }
561
562
  public void viewStatistics() {
563
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
564
  }
565
566
  public void viewFiles() {
567
    try {
568
      final var factory = new FilePickerFactory( mWorkspace );
569
      final var fileManager = factory.createModeless();
570
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
571
    } catch( final Exception ex ) {
572
      clue( ex );
573
    }
574
  }
575
576
  private void viewTab(
577
    final Node node, final MediaType mediaType, final String key ) {
578
    final var tabPane = obtainTabPane( mediaType );
579
580
    for( final var tab : tabPane.getTabs() ) {
581
      if( tab.getContent() == node ) {
582
        return;
583
      }
584
    }
585
586
    tabPane.getTabs().add( createTab( get( key ), node ) );
587
    addTabPane( tabPane );
588
  }
589
590
  public void viewRefresh() {
591
    mPreview.refresh();
592
  }
593
594
  /**
595
   * Returns the tab that contains the given {@link TextEditor}.
596
   *
597
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
598
   * @return The first tab having content that matches the given tab.
599
   */
600
  private Optional<Tab> getTab( final TextResource editor ) {
601
    return mTabPanes.values()
602
                    .stream()
603
                    .flatMap( pane -> pane.getTabs().stream() )
604
                    .filter( tab -> editor.equals( tab.getContent() ) )
605
                    .findFirst();
606
  }
607
608
  /**
609
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
610
   * is used to detect when the active {@link DefinitionEditor} has changed.
611
   * Upon changing, the {@link #mResolvedMap} is updated and the active
612
   * text editor is refreshed.
613
   *
614
   * @param editor Text editor to update with the revised resolved map.
615
   * @return A newly configured property that represents the active
616
   * {@link DefinitionEditor}, never null.
617
   */
618
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
619
    final ObjectProperty<TextEditor> editor ) {
620
    final var definitions = new SimpleObjectProperty<TextDefinition>();
621
    definitions.addListener( ( c, o, n ) -> {
622
      resolve( n == null ? createDefinitionEditor() : n );
623
      process( editor.get() );
624
    } );
625
626
    return definitions;
627
  }
628
629
  private Tab createTab( final String filename, final Node node ) {
630
    return new DetachableTab( filename, node );
631
  }
632
633
  private Tab createTab( final File file ) {
634
    final var r = createTextResource( file );
635
    final var tab = createTab( r.getFilename(), r.getNode() );
636
637
    r.modifiedProperty().addListener(
638
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
639
    );
640
641
    // This is called when either the tab is closed by the user clicking on
642
    // the tab's close icon or when closing (all) from the file menu.
643
    tab.setOnClosed(
644
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
645
    );
646
647
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
648
      if( nPane != null ) {
649
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
650
          if( n != null && n ) {
651
            final var selected = nPane.getSelectionModel().getSelectedItem();
652
            final var node = selected.getContent();
653
            node.requestFocus();
654
          }
655
        } );
656
      }
657
    } );
658
659
    return tab;
660
  }
661
662
  /**
663
   * Creates bins for the different {@link MediaType}s, which eventually are
664
   * added to the UI as separate tab panes. If ever a general-purpose scene
665
   * exporter is developed to serialize a scene to an FXML file, this could
666
   * be replaced by such a class.
667
   * <p>
668
   * When binning the files, this makes sure that at least one file exists
669
   * for every type. If the user has opted to close a particular type (such
670
   * as the definition pane), the view will suppressed elsewhere.
671
   * </p>
672
   * <p>
673
   * The order that the binned files are returned will be reflected in the
674
   * order that the corresponding panes are rendered in the UI.
675
   * </p>
676
   *
677
   * @param paths The file paths to bin according to their type.
678
   * @return An in-order list of files, first by structured definition files,
679
   * then by plain text documents.
680
   */
681
  private List<File> bin( final SetProperty<String> paths ) {
682
    // Treat all files destined for the text editor as plain text documents
683
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
684
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
685
    final Function<MediaType, MediaType> bin =
686
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
687
688
    // Create two groups: YAML files and plain text files.
689
    final var bins = paths
690
      .stream()
691
      .collect(
692
        groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
693
      );
694
695
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
696
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
697
698
    final var result = new ArrayList<File>( paths.size() );
699
700
    // Ensure that the same types are listed together (keep insertion order).
701
    bins.forEach( ( mediaType, files ) -> result.addAll(
702
      files.stream().map( File::new ).collect( Collectors.toList() ) )
703
    );
704
705
    return result;
706
  }
707
708
  /**
709
   * Uses the given {@link TextDefinition} instance to update the
710
   * {@link #mResolvedMap}.
711
   *
712
   * @param editor A non-null, possibly empty definition editor.
713
   */
714
  private void resolve( final TextDefinition editor ) {
715
    assert editor != null;
716
717
    final var tokens = createDefinitionTokens();
718
    final var operator = new YamlSigilOperator( tokens );
719
    final var map = new HashMap<String, String>();
720
721
    editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
722
723
    mResolvedMap.clear();
724
    mResolvedMap.putAll( editor.interpolate( map, tokens ) );
725
  }
726
727
  /**
728
   * Force the active editor to update, which will cause the processor
729
   * to re-evaluate the interpolated definition map thereby updating the
730
   * preview pane.
731
   *
732
   * @param editor Contains the source document to update in the preview pane.
733
   */
734
  private void process( final TextEditor editor ) {
735
    // Ensure processing does not run on the JavaFX thread, which frees the
736
    // text editor immediately for caret movement. The preview will have a
737
    // slight delay when catching up to the caret position.
738
    final var task = new Task<Void>() {
739
      @Override
740
      public Void call() {
741
        try {
742
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
743
          p.apply( editor == null ? "" : editor.getText() );
744
        } catch( final Exception ex ) {
745
          clue( ex );
746
        }
747
748
        return null;
749
      }
750
    };
751
752
    task.setOnSucceeded(
753
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
754
    );
755
756
    // Prevents multiple process requests from executing simultaneously (due
757
    // to having a restricted queue size).
758
    sExecutor.execute( task );
759
  }
760
761
  /**
762
   * Lazily creates a {@link TabPane} configured to listen for tab select
763
   * events. The tab pane is associated with a given media type so that
764
   * similar files can be grouped together.
765
   *
766
   * @param mediaType The media type to associate with the tab pane.
767
   * @return An instance of {@link TabPane} that will handle tab docking.
768
   */
769
  private TabPane obtainTabPane( final MediaType mediaType ) {
770
    return mTabPanes.computeIfAbsent(
771
      mediaType, ( mt ) -> createTabPane()
772
    );
773
  }
774
775
  /**
776
   * Creates an initialized {@link TabPane} instance.
777
   *
778
   * @return A new {@link TabPane} with all listeners configured.
779
   */
780
  private TabPane createTabPane() {
781
    final var tabPane = new DetachableTabPane();
782
783
    initStageOwnerFactory( tabPane );
784
    initTabListener( tabPane );
785
786
    return tabPane;
787
  }
788
789
  /**
790
   * When any {@link DetachableTabPane} is detached from the main window,
791
   * the stage owner factory must be given its parent window, which will
792
   * own the child window. The parent window is the {@link MainPane}'s
793
   * {@link Scene}'s {@link Window} instance.
794
   *
795
   * <p>
796
   * This will derives the new title from the main window title, incrementing
797
   * the window count to help uniquely identify the child windows.
798
   * </p>
799
   *
800
   * @param tabPane A new {@link DetachableTabPane} to configure.
801
   */
802
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
803
    tabPane.setStageOwnerFactory( ( stage ) -> {
804
      final var title = get(
805
        "Detach.tab.title",
806
        ((Stage) getWindow()).getTitle(), ++mWindowCount
807
      );
808
      stage.setTitle( title );
809
810
      return getScene().getWindow();
811
    } );
812
  }
813
814
  /**
815
   * Responsible for configuring the content of each {@link DetachableTab} when
816
   * it is added to the given {@link DetachableTabPane} instance.
817
   * <p>
818
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
819
   * is initialized to perform synchronized scrolling between the editor and
820
   * its preview window. Additionally, the last tab in the tab pane's list of
821
   * tabs is given focus.
822
   * </p>
823
   * <p>
824
   * Note that multiple tabs can be added simultaneously.
825
   * </p>
826
   *
827
   * @param tabPane A new {@link TabPane} to configure.
828
   */
829
  private void initTabListener( final TabPane tabPane ) {
830
    tabPane.getTabs().addListener(
831
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
832
        while( listener.next() ) {
833
          if( listener.wasAdded() ) {
834
            final var tabs = listener.getAddedSubList();
835
836
            tabs.forEach( ( tab ) -> {
837
              final var node = tab.getContent();
838
839
              if( node instanceof TextEditor ) {
840
                initScrollEventListener( tab );
841
              }
842
            } );
843
844
            // Select and give focus to the last tab opened.
845
            final var index = tabs.size() - 1;
846
            if( index >= 0 ) {
847
              final var tab = tabs.get( index );
848
              tabPane.getSelectionModel().select( tab );
849
              tab.getContent().requestFocus();
850
            }
851
          }
852
        }
853
      }
854
    );
855
  }
856
857
  /**
858
   * Synchronizes scrollbar positions between the given {@link Tab} that
859
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
860
   *
861
   * @param tab The container for an instance of {@link TextEditor}.
862
   */
863
  private void initScrollEventListener( final Tab tab ) {
864
    final var editor = (TextEditor) tab.getContent();
865
    final var scrollPane = editor.getScrollPane();
866
    final var scrollBar = mPreview.getVerticalScrollBar();
867
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
868
    handler.enabledProperty().bind( tab.selectedProperty() );
869
  }
870
871
  private void addTabPane( final int index, final TabPane tabPane ) {
872
    final var items = getItems();
873
    if( !items.contains( tabPane ) ) {
874
      items.add( index, tabPane );
875
    }
876
  }
877
878
  private void addTabPane( final TabPane tabPane ) {
879
    addTabPane( getItems().size(), tabPane );
880
  }
881
882
  public ProcessorContext createProcessorContext() {
883
    return createProcessorContext( null, NONE );
884
  }
885
886
  public ProcessorContext createProcessorContext(
887
    final Path exportPath, final ExportFormat format ) {
888
    final var editor = getActiveTextEditor();
889
    return createProcessorContext(
890
      editor.getPath(), exportPath, format, editor.getCaret() );
891
  }
892
893
  private ProcessorContext createProcessorContext(
894
    final Path path, final Caret caret ) {
895
    return createProcessorContext( path, null, ExportFormat.NONE, caret );
896
  }
897
898
  /**
899
   * @param path       Used by {@link ProcessorFactory} to determine
900
   *                   {@link Processor} type to create based on file type.
901
   * @param exportPath Used when exporting to a PDF file (binary).
902
   * @param format     Used when processors export to a new text format.
903
   * @param caret      Used by {@link CaretExtension} to add ID attribute into
904
   *                   preview document for scrollbar synchronization.
905
   * @return A new {@link ProcessorContext} to use when creating an instance of
906
   * {@link Processor}.
907
   */
908
  private ProcessorContext createProcessorContext(
909
    final Path path, final Path exportPath, final ExportFormat format,
910
    final Caret caret ) {
911
    return new ProcessorContext(
912
      mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret
913
    );
914
  }
915
916
  private TextResource createTextResource( final File file ) {
917
    // TODO: Create PlainTextEditor that's returned by default.
918
    return MediaType.valueFrom( file ) == TEXT_YAML
919
      ? createDefinitionEditor( file )
920
      : createMarkdownEditor( file );
921
  }
922
923
  /**
924
   * Creates an instance of {@link MarkdownEditor} that listens for both
925
   * caret change events and text change events. Text change events must
926
   * take priority over caret change events because it's possible to change
927
   * the text without moving the caret (e.g., delete selected text).
928
   *
929
   * @param file The file containing contents for the text editor.
930
   * @return A non-null text editor.
931
   */
932
  private TextResource createMarkdownEditor( final File file ) {
933
    final var path = file.toPath();
934
    final var editor = new MarkdownEditor( file, getWorkspace() );
935
    final var caret = editor.getCaret();
936
    final var context = createProcessorContext( path, caret );
937
938
    mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
939
940
    editor.addDirtyListener( ( c, o, n ) -> {
941
      if( n ) {
942
        // Reset the status to OK after changing the text.
943
        clue();
944
945
        // Processing the text may update the status bar.
946
        process( getActiveTextEditor() );
947
      }
948
    } );
949
950
    editor.addEventListener(
951
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
952
    );
953
954
    // Set the active editor, which refreshes the preview panel.
955
    mActiveTextEditor.set( editor );
956
957
    return editor;
958
  }
959
960
  /**
961
   * Delegates to {@link #autoinsert()}.
962
   *
963
   * @param event Ignored.
964
   */
965
  private void autoinsert( final KeyEvent event ) {
966
    autoinsert();
967
  }
968
969
  /**
970
   * Finds a node that matches the word at the caret, then inserts the
971
   * corresponding definition. The definition token delimiters depend on
972
   * the type of file being edited.
973
   */
974
  public void autoinsert() {
975
    final var definitions = getActiveTextDefinition();
976
    final var editor = getActiveTextEditor();
977
    final var mediaType = editor.getMediaType();
978
    final var operator = getSigilOperator( mediaType );
979
980
    DefinitionNameInjector.autoinsert( editor, definitions, operator );
981
  }
982
983
  private TextDefinition createDefinitionEditor() {
984
    return createDefinitionEditor( DEFINITION_DEFAULT );
985
  }
986
987
  private TextDefinition createDefinitionEditor( final File file ) {
988
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
989
    editor.addTreeChangeHandler( mTreeHandler );
990
    return editor;
991
  }
992
993
  private TreeTransformer createTreeTransformer() {
994
    return new YamlTreeTransformer();
995
  }
996
997
  private Tooltip createTooltip( final File file ) {
998
    final var path = file.toPath();
999
    final var tooltip = new Tooltip( path.toString() );
1000
1001
    tooltip.setShowDelay( millis( 200 ) );
1002
    return tooltip;
1003
  }
1004
1005
  public TextEditor getActiveTextEditor() {
1006
    return mActiveTextEditor.get();
1007
  }
1008
1009
  public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() {
1010
    return mActiveTextEditor;
1011
  }
1012
1013
  public TextDefinition getActiveTextDefinition() {
1014
    return mActiveDefinitionEditor.get();
1015
  }
1016
1017
  public Window getWindow() {
1018
    return getScene().getWindow();
1019
  }
1020
1021
  public Workspace getWorkspace() {
1022
    return mWorkspace;
1023
  }
1024
1025
  /**
1026
   * Returns the sigil operator for the given {@link MediaType}.
1027
   *
1028
   * @param mediaType The type of file being edited.
1029
   */
1030
  private SigilOperator getSigilOperator( final MediaType mediaType ) {
1031
    final var operator = new YamlSigilOperator( createDefinitionTokens() );
1032
1033
    return switch( mediaType ) {
1034
      case TEXT_R_MARKDOWN, TEXT_R_XML -> new RSigilOperator(
1035
        createRTokens(), operator );
1036
      default -> operator;
1037
    };
97
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
98
  );
99
100
  /**
101
   * Prevents re-instantiation of processing classes.
102
   */
103
  private final Map<TextResource, Processor<String>> mProcessors =
104
    new HashMap<>();
105
106
  private final Workspace mWorkspace;
107
108
  /**
109
   * Groups similar file type tabs together.
110
   */
111
  private final List<TabPane> mTabPanes = new ArrayList<>();
112
113
  /**
114
   * Stores definition names and values.
115
   */
116
  private final Map<String, String> mResolvedMap =
117
    new HashMap<>( MAP_SIZE_DEFAULT );
118
119
  /**
120
   * Renders the actively selected plain text editor tab.
121
   */
122
  private final HtmlPreview mPreview;
123
124
  /**
125
   * Provides an interactive document outline.
126
   */
127
  private final DocumentOutline mOutline = new DocumentOutline();
128
129
  /**
130
   * Changing the active editor fires the value changed event. This allows
131
   * refreshes to happen when external definitions are modified and need to
132
   * trigger the processing chain.
133
   */
134
  private final ObjectProperty<TextEditor> mActiveTextEditor =
135
    createActiveTextEditor();
136
137
  /**
138
   * Changing the active definition editor fires the value changed event. This
139
   * allows refreshes to happen when external definitions are modified and need
140
   * to trigger the processing chain.
141
   */
142
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
143
    createActiveDefinitionEditor( mActiveTextEditor );
144
145
  /**
146
   * Tracks the number of detached tab panels opened into their own windows,
147
   * which allows unique identification of subordinate windows by their title.
148
   * It is doubtful more than 128 windows, much less 256, will be created.
149
   */
150
  private byte mWindowCount;
151
152
  /**
153
   * Called when the definition data is changed.
154
   */
155
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
156
    event -> {
157
      final var editor = mActiveDefinitionEditor.get();
158
159
      resolve( editor );
160
      process( getActiveTextEditor() );
161
      save( editor );
162
    };
163
164
  private final DocumentStatistics mStatistics;
165
166
  /**
167
   * Adds all content panels to the main user interface. This will load the
168
   * configuration settings from the workspace to reproduce the settings from
169
   * a previous session.
170
   */
171
  public MainPane( final Workspace workspace ) {
172
    mWorkspace = workspace;
173
    mPreview = new HtmlPreview( workspace );
174
    mStatistics = new DocumentStatistics( workspace );
175
    mActiveTextEditor.set( new MarkdownEditor( workspace ) );
176
177
    open( bin( getRecentFiles() ) );
178
    viewPreview();
179
    setDividerPositions( calculateDividerPositions() );
180
181
    // Once the main scene's window regains focus, update the active definition
182
    // editor to the currently selected tab.
183
    runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
184
      // Order matters here. We want to close all the tabs to ensure each
185
      // is saved, but after they are closed, the workspace should still
186
      // retain the list of files that were open. If this line came after
187
      // closing, then restarting the application would list no files.
188
      mWorkspace.save();
189
190
      if( closeAll() ) {
191
        Platform.exit();
192
        System.exit( 0 );
193
      }
194
      else {
195
        event.consume();
196
      }
197
    } ) );
198
199
    register( this );
200
  }
201
202
  @Subscribe
203
  public void handle( final TextEditorFocusEvent event ) {
204
    mActiveTextEditor.set( event.get() );
205
  }
206
207
  @Subscribe
208
  public void handle( final TextDefinitionFocusEvent event ) {
209
    mActiveDefinitionEditor.set( event.get() );
210
  }
211
212
  /**
213
   * Typically called when a file name is clicked in the {@link HtmlPanel}.
214
   *
215
   * @param event The event to process, must contain a valid file reference.
216
   */
217
  @Subscribe
218
  public void handle( final FileOpenEvent event ) {
219
    final File eventFile;
220
    final var eventUri = event.getUri();
221
222
    if( eventUri.isAbsolute() ) {
223
      eventFile = new File( eventUri.getPath() );
224
    }
225
    else {
226
      final var activeFile = getActiveTextEditor().getFile();
227
      final var parent = activeFile.getParentFile();
228
229
      if( parent == null ) {
230
        clue( new FileNotFoundException( eventUri.getPath() ) );
231
        return;
232
      }
233
      else {
234
        final var parentPath = parent.getAbsolutePath();
235
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
236
      }
237
    }
238
239
    runLater( () -> open( eventFile ) );
240
  }
241
242
  @Subscribe
243
  public void handle( final CaretNavigationEvent event ) {
244
    runLater( () -> {
245
      final var textArea = getActiveTextEditor().getTextArea();
246
      textArea.moveTo( event.getOffset() );
247
      textArea.requestFollowCaret();
248
      textArea.requestFocus();
249
    } );
250
  }
251
252
  @Subscribe
253
  @SuppressWarnings( "unused" )
254
  public void handle( final ExportFailedEvent event ) {
255
    final var os = getProperty( "os.name" );
256
    final var arch = getProperty( "os.arch" ).toLowerCase();
257
    final var bits = getProperty( "sun.arch.data.model" );
258
259
    final var title = Messages.get( "Alert.typesetter.missing.title" );
260
    final var header = Messages.get( "Alert.typesetter.missing.header" );
261
    final var version = Messages.get(
262
      "Alert.typesetter.missing.version",
263
      os,
264
      arch
265
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
266
        .replaceAll( "mips.*", "MIPS" )
267
        .replaceAll( "armv.*", "ARM" ),
268
      bits );
269
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
270
271
    // Download and install ConTeXt for {0} {1} {2}-bit
272
    final var content = format( "%s %s", text, version );
273
    final var flowPane = new FlowPane();
274
    final var link = new Hyperlink( text );
275
    final var label = new Label( version );
276
    flowPane.getChildren().addAll( link, label );
277
278
    final var alert = new Alert( ERROR, content, OK );
279
    alert.setTitle( title );
280
    alert.setHeaderText( header );
281
    alert.getDialogPane().contentProperty().set( flowPane );
282
    alert.setGraphic( ICON_DIALOG_NODE );
283
284
    link.setOnAction( ( e ) -> {
285
      alert.close();
286
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
287
      runLater( () -> fireHyperlinkOpenEvent( url ) );
288
    } );
289
290
    alert.showAndWait();
291
  }
292
293
  /**
294
   * TODO: Load divider positions from exported settings, see bin() comment.
295
   */
296
  private double[] calculateDividerPositions() {
297
    final var ratio = 100f / getItems().size() / 100;
298
    final var positions = getDividerPositions();
299
300
    for( int i = 0; i < positions.length; i++ ) {
301
      positions[ i ] = ratio * i;
302
    }
303
304
    return positions;
305
  }
306
307
  /**
308
   * Opens all the files into the application, provided the paths are unique.
309
   * This may only be called for any type of files that a user can edit
310
   * (i.e., update and persist), such as definitions and text files.
311
   *
312
   * @param files The list of files to open.
313
   */
314
  public void open( final List<File> files ) {
315
    files.forEach( this::open );
316
  }
317
318
  /**
319
   * This opens the given file. Since the preview pane is not a file that
320
   * can be opened, it is safe to add a listener to the detachable pane.
321
   *
322
   * @param file The file to open.
323
   */
324
  private void open( final File file ) {
325
    final var tab = createTab( file );
326
    final var node = tab.getContent();
327
    final var mediaType = MediaType.valueFrom( file );
328
    final var tabPane = obtainTabPane( mediaType );
329
330
    tab.setTooltip( createTooltip( file ) );
331
    tabPane.setFocusTraversable( false );
332
    tabPane.setTabClosingPolicy( ALL_TABS );
333
    tabPane.getTabs().add( tab );
334
335
    // Attach the tab scene factory for new tab panes.
336
    if( !getItems().contains( tabPane ) ) {
337
      addTabPane(
338
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
339
      );
340
    }
341
342
    getRecentFiles().add( file.getAbsolutePath() );
343
  }
344
345
  /**
346
   * Opens a new text editor document using the default document file name.
347
   */
348
  public void newTextEditor() {
349
    open( DOCUMENT_DEFAULT );
350
  }
351
352
  /**
353
   * Opens a new definition editor document using the default definition
354
   * file name.
355
   */
356
  public void newDefinitionEditor() {
357
    open( DEFINITION_DEFAULT );
358
  }
359
360
  /**
361
   * Iterates over all tab panes to find all {@link TextEditor}s and request
362
   * that they save themselves.
363
   */
364
  public void saveAll() {
365
    mTabPanes.forEach(
366
      ( tp ) -> tp.getTabs().forEach( ( tab ) -> {
367
        final var node = tab.getContent();
368
        if( node instanceof final TextEditor editor ) {
369
          save( editor );
370
        }
371
      } )
372
    );
373
  }
374
375
  /**
376
   * Requests that the active {@link TextEditor} saves itself. Don't bother
377
   * checking if modified first because if the user swaps external media from
378
   * an external source (e.g., USB thumb drive), save should not second-guess
379
   * the user: save always re-saves. Also, it's less code.
380
   */
381
  public void save() {
382
    save( getActiveTextEditor() );
383
  }
384
385
  /**
386
   * Saves the active {@link TextEditor} under a new name.
387
   *
388
   * @param files The new active editor {@link File} reference, must contain
389
   *              at least one element.
390
   */
391
  public void saveAs( final List<File> files ) {
392
    assert files != null;
393
    assert !files.isEmpty();
394
    final var editor = getActiveTextEditor();
395
    final var tab = getTab( editor );
396
    final var file = files.get( 0 );
397
398
    editor.rename( file );
399
    tab.ifPresent( t -> {
400
      t.setText( editor.getFilename() );
401
      t.setTooltip( createTooltip( file ) );
402
    } );
403
404
    save();
405
  }
406
407
  /**
408
   * Saves the given {@link TextResource} to a file. This is typically used
409
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
410
   *
411
   * @param resource The resource to export.
412
   */
413
  private void save( final TextResource resource ) {
414
    try {
415
      resource.save();
416
    } catch( final Exception ex ) {
417
      clue( ex );
418
      sNotifier.alert(
419
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
420
      );
421
    }
422
  }
423
424
  /**
425
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
426
   *
427
   * @return {@code true} when all editors, modified or otherwise, were
428
   * permitted to close; {@code false} when one or more editors were modified
429
   * and the user requested no closing.
430
   */
431
  public boolean closeAll() {
432
    var closable = true;
433
434
    for( final var tabPane : mTabPanes ) {
435
      final var tabIterator = tabPane.getTabs().iterator();
436
437
      while( tabIterator.hasNext() ) {
438
        final var tab = tabIterator.next();
439
        final var resource = tab.getContent();
440
441
        // The definition panes auto-save, so being specific here prevents
442
        // closing the definitions in the situation where the user wants to
443
        // continue editing (i.e., possibly save unsaved work).
444
        if( !(resource instanceof TextEditor) ) {
445
          continue;
446
        }
447
448
        if( canClose( (TextEditor) resource ) ) {
449
          tabIterator.remove();
450
          close( tab );
451
        }
452
        else {
453
          closable = false;
454
        }
455
      }
456
    }
457
458
    return closable;
459
  }
460
461
  /**
462
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
463
   * event.
464
   *
465
   * @param tab The {@link Tab} that was closed.
466
   */
467
  private void close( final Tab tab ) {
468
    final var handler = tab.getOnClosed();
469
470
    if( handler != null ) {
471
      handler.handle( new ActionEvent() );
472
    }
473
  }
474
475
  /**
476
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
477
   */
478
  public void close() {
479
    final var editor = getActiveTextEditor();
480
481
    if( canClose( editor ) ) {
482
      close( editor );
483
    }
484
  }
485
486
  /**
487
   * Closes the given {@link TextResource}. This must not be called from within
488
   * a loop that iterates over the tab panes using {@code forEach}, lest a
489
   * concurrent modification exception be thrown.
490
   *
491
   * @param resource The {@link TextResource} to close, without confirming with
492
   *                 the user.
493
   */
494
  private void close( final TextResource resource ) {
495
    getTab( resource ).ifPresent(
496
      ( tab ) -> {
497
        tab.getTabPane().getTabs().remove( tab );
498
        close( tab );
499
      }
500
    );
501
  }
502
503
  /**
504
   * Answers whether the given {@link TextResource} may be closed.
505
   *
506
   * @param editor The {@link TextResource} to try closing.
507
   * @return {@code true} when the editor may be closed; {@code false} when
508
   * the user has requested to keep the editor open.
509
   */
510
  private boolean canClose( final TextResource editor ) {
511
    final var editorTab = getTab( editor );
512
    final var canClose = new AtomicBoolean( true );
513
514
    if( editor.isModified() ) {
515
      final var filename = new StringBuilder();
516
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
517
518
      final var message = sNotifier.createNotification(
519
        Messages.get( "Alert.file.close.title" ),
520
        Messages.get( "Alert.file.close.text" ),
521
        filename.toString()
522
      );
523
524
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
525
526
      dialog.showAndWait().ifPresent(
527
        save -> canClose.set( save == YES ? editor.save() : save == NO )
528
      );
529
    }
530
531
    return canClose.get();
532
  }
533
534
  private ObjectProperty<TextEditor> createActiveTextEditor() {
535
    final var editor = new SimpleObjectProperty<TextEditor>();
536
537
    editor.addListener( ( c, o, n ) -> {
538
      if( n != null ) {
539
        mPreview.setBaseUri( n.getPath() );
540
        process( n );
541
      }
542
    } );
543
544
    return editor;
545
  }
546
547
  /**
548
   * Adds the HTML preview tab to its own, singular tab pane.
549
   */
550
  public void viewPreview() {
551
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
552
  }
553
554
  /**
555
   * Adds the document outline tab to its own, singular tab pane.
556
   */
557
  public void viewOutline() {
558
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
559
  }
560
561
  public void viewStatistics() {
562
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
563
  }
564
565
  public void viewFiles() {
566
    try {
567
      final var factory = new FilePickerFactory( mWorkspace );
568
      final var fileManager = factory.createModeless();
569
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
570
    } catch( final Exception ex ) {
571
      clue( ex );
572
    }
573
  }
574
575
  private void viewTab(
576
    final Node node, final MediaType mediaType, final String key ) {
577
    final var tabPane = obtainTabPane( mediaType );
578
579
    for( final var tab : tabPane.getTabs() ) {
580
      if( tab.getContent() == node ) {
581
        return;
582
      }
583
    }
584
585
    tabPane.getTabs().add( createTab( get( key ), node ) );
586
    addTabPane( tabPane );
587
  }
588
589
  public void viewRefresh() {
590
    mPreview.refresh();
591
  }
592
593
  /**
594
   * Returns the tab that contains the given {@link TextEditor}.
595
   *
596
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
597
   * @return The first tab having content that matches the given tab.
598
   */
599
  private Optional<Tab> getTab( final TextResource editor ) {
600
    return mTabPanes.stream()
601
                    .flatMap( pane -> pane.getTabs().stream() )
602
                    .filter( tab -> editor.equals( tab.getContent() ) )
603
                    .findFirst();
604
  }
605
606
  /**
607
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
608
   * is used to detect when the active {@link DefinitionEditor} has changed.
609
   * Upon changing, the {@link #mResolvedMap} is updated and the active
610
   * text editor is refreshed.
611
   *
612
   * @param editor Text editor to update with the revised resolved map.
613
   * @return A newly configured property that represents the active
614
   * {@link DefinitionEditor}, never null.
615
   */
616
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
617
    final ObjectProperty<TextEditor> editor ) {
618
    final var definitions = new SimpleObjectProperty<TextDefinition>();
619
    definitions.addListener( ( c, o, n ) -> {
620
      resolve( n == null ? createDefinitionEditor() : n );
621
      process( editor.get() );
622
    } );
623
624
    return definitions;
625
  }
626
627
  private Tab createTab( final String filename, final Node node ) {
628
    return new DetachableTab( filename, node );
629
  }
630
631
  private Tab createTab( final File file ) {
632
    final var r = createTextResource( file );
633
    final var tab = createTab( r.getFilename(), r.getNode() );
634
635
    r.modifiedProperty().addListener(
636
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
637
    );
638
639
    // This is called when either the tab is closed by the user clicking on
640
    // the tab's close icon or when closing (all) from the file menu.
641
    tab.setOnClosed(
642
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
643
    );
644
645
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
646
      if( nPane != null ) {
647
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
648
          if( n != null && n ) {
649
            final var selected = nPane.getSelectionModel().getSelectedItem();
650
            final var node = selected.getContent();
651
            node.requestFocus();
652
          }
653
        } );
654
      }
655
    } );
656
657
    return tab;
658
  }
659
660
  /**
661
   * Creates bins for the different {@link MediaType}s, which eventually are
662
   * added to the UI as separate tab panes. If ever a general-purpose scene
663
   * exporter is developed to serialize a scene to an FXML file, this could
664
   * be replaced by such a class.
665
   * <p>
666
   * When binning the files, this makes sure that at least one file exists
667
   * for every type. If the user has opted to close a particular type (such
668
   * as the definition pane), the view will suppressed elsewhere.
669
   * </p>
670
   * <p>
671
   * The order that the binned files are returned will be reflected in the
672
   * order that the corresponding panes are rendered in the UI.
673
   * </p>
674
   *
675
   * @param paths The file paths to bin according to their type.
676
   * @return An in-order list of files, first by structured definition files,
677
   * then by plain text documents.
678
   */
679
  private List<File> bin( final SetProperty<String> paths ) {
680
    // Treat all files destined for the text editor as plain text documents
681
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
682
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
683
    final Function<MediaType, MediaType> bin =
684
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
685
686
    // Create two groups: YAML files and plain text files.
687
    final var bins = paths
688
      .stream()
689
      .collect(
690
        groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) )
691
      );
692
693
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
694
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
695
696
    final var result = new ArrayList<File>( paths.size() );
697
698
    // Ensure that the same types are listed together (keep insertion order).
699
    bins.forEach( ( mediaType, files ) -> result.addAll(
700
      files.stream().map( File::new ).collect( Collectors.toList() ) )
701
    );
702
703
    return result;
704
  }
705
706
  /**
707
   * Uses the given {@link TextDefinition} instance to update the
708
   * {@link #mResolvedMap}.
709
   *
710
   * @param editor A non-null, possibly empty definition editor.
711
   */
712
  private void resolve( final TextDefinition editor ) {
713
    assert editor != null;
714
715
    final var tokens = createDefinitionTokens();
716
    final var operator = new YamlSigilOperator( tokens );
717
    final var map = new HashMap<String, String>();
718
719
    editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
720
721
    mResolvedMap.clear();
722
    mResolvedMap.putAll( editor.interpolate( map, tokens ) );
723
  }
724
725
  /**
726
   * Force the active editor to update, which will cause the processor
727
   * to re-evaluate the interpolated definition map thereby updating the
728
   * preview pane.
729
   *
730
   * @param editor Contains the source document to update in the preview pane.
731
   */
732
  private void process( final TextEditor editor ) {
733
    // Ensure processing does not run on the JavaFX thread, which frees the
734
    // text editor immediately for caret movement. The preview will have a
735
    // slight delay when catching up to the caret position.
736
    final var task = new Task<Void>() {
737
      @Override
738
      public Void call() {
739
        try {
740
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
741
          p.apply( editor == null ? "" : editor.getText() );
742
        } catch( final Exception ex ) {
743
          clue( ex );
744
        }
745
746
        return null;
747
      }
748
    };
749
750
    task.setOnSucceeded(
751
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
752
    );
753
754
    // Prevents multiple process requests from executing simultaneously (due
755
    // to having a restricted queue size).
756
    sExecutor.execute( task );
757
  }
758
759
  /**
760
   * Lazily creates a {@link TabPane} configured to listen for tab select
761
   * events. The tab pane is associated with a given media type so that
762
   * similar files can be grouped together.
763
   *
764
   * @param mediaType The media type to associate with the tab pane.
765
   * @return An instance of {@link TabPane} that will handle tab docking.
766
   */
767
  private TabPane obtainTabPane( final MediaType mediaType ) {
768
    for( final var pane : mTabPanes ) {
769
      for( final var tab : pane.getTabs() ) {
770
        final var node = tab.getContent();
771
772
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
773
          return pane;
774
        }
775
      }
776
    }
777
778
    final var pane = createTabPane();
779
    mTabPanes.add( pane );
780
    return pane;
781
  }
782
783
  /**
784
   * Creates an initialized {@link TabPane} instance.
785
   *
786
   * @return A new {@link TabPane} with all listeners configured.
787
   */
788
  private TabPane createTabPane() {
789
    final var tabPane = new DetachableTabPane();
790
791
    initStageOwnerFactory( tabPane );
792
    initTabListener( tabPane );
793
794
    return tabPane;
795
  }
796
797
  /**
798
   * When any {@link DetachableTabPane} is detached from the main window,
799
   * the stage owner factory must be given its parent window, which will
800
   * own the child window. The parent window is the {@link MainPane}'s
801
   * {@link Scene}'s {@link Window} instance.
802
   *
803
   * <p>
804
   * This will derives the new title from the main window title, incrementing
805
   * the window count to help uniquely identify the child windows.
806
   * </p>
807
   *
808
   * @param tabPane A new {@link DetachableTabPane} to configure.
809
   */
810
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
811
    tabPane.setStageOwnerFactory( ( stage ) -> {
812
      final var title = get(
813
        "Detach.tab.title",
814
        ((Stage) getWindow()).getTitle(), ++mWindowCount
815
      );
816
      stage.setTitle( title );
817
818
      return getScene().getWindow();
819
    } );
820
  }
821
822
  /**
823
   * Responsible for configuring the content of each {@link DetachableTab} when
824
   * it is added to the given {@link DetachableTabPane} instance.
825
   * <p>
826
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
827
   * is initialized to perform synchronized scrolling between the editor and
828
   * its preview window. Additionally, the last tab in the tab pane's list of
829
   * tabs is given focus.
830
   * </p>
831
   * <p>
832
   * Note that multiple tabs can be added simultaneously.
833
   * </p>
834
   *
835
   * @param tabPane A new {@link TabPane} to configure.
836
   */
837
  private void initTabListener( final TabPane tabPane ) {
838
    tabPane.getTabs().addListener(
839
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
840
        while( listener.next() ) {
841
          if( listener.wasAdded() ) {
842
            final var tabs = listener.getAddedSubList();
843
844
            tabs.forEach( ( tab ) -> {
845
              final var node = tab.getContent();
846
847
              if( node instanceof TextEditor ) {
848
                initScrollEventListener( tab );
849
              }
850
            } );
851
852
            // Select and give focus to the last tab opened.
853
            final var index = tabs.size() - 1;
854
            if( index >= 0 ) {
855
              final var tab = tabs.get( index );
856
              tabPane.getSelectionModel().select( tab );
857
              tab.getContent().requestFocus();
858
            }
859
          }
860
        }
861
      }
862
    );
863
  }
864
865
  /**
866
   * Synchronizes scrollbar positions between the given {@link Tab} that
867
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
868
   *
869
   * @param tab The container for an instance of {@link TextEditor}.
870
   */
871
  private void initScrollEventListener( final Tab tab ) {
872
    final var editor = (TextEditor) tab.getContent();
873
    final var scrollPane = editor.getScrollPane();
874
    final var scrollBar = mPreview.getVerticalScrollBar();
875
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
876
    handler.enabledProperty().bind( tab.selectedProperty() );
877
  }
878
879
  private void addTabPane( final int index, final TabPane tabPane ) {
880
    final var items = getItems();
881
    if( !items.contains( tabPane ) ) {
882
      items.add( index, tabPane );
883
    }
884
  }
885
886
  private void addTabPane( final TabPane tabPane ) {
887
    addTabPane( getItems().size(), tabPane );
888
  }
889
890
  public ProcessorContext createProcessorContext() {
891
    return createProcessorContext( null, NONE );
892
  }
893
894
  public ProcessorContext createProcessorContext(
895
    final Path exportPath, final ExportFormat format ) {
896
    final var editor = getActiveTextEditor();
897
    return createProcessorContext(
898
      editor.getPath(), exportPath, format, editor.getCaret() );
899
  }
900
901
  private ProcessorContext createProcessorContext(
902
    final Path path, final Caret caret ) {
903
    return createProcessorContext( path, null, ExportFormat.NONE, caret );
904
  }
905
906
  /**
907
   * @param path       Used by {@link ProcessorFactory} to determine
908
   *                   {@link Processor} type to create based on file type.
909
   * @param exportPath Used when exporting to a PDF file (binary).
910
   * @param format     Used when processors export to a new text format.
911
   * @param caret      Used by {@link CaretExtension} to add ID attribute into
912
   *                   preview document for scrollbar synchronization.
913
   * @return A new {@link ProcessorContext} to use when creating an instance of
914
   * {@link Processor}.
915
   */
916
  private ProcessorContext createProcessorContext(
917
    final Path path, final Path exportPath, final ExportFormat format,
918
    final Caret caret ) {
919
    return new ProcessorContext(
920
      mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret
921
    );
922
  }
923
924
  private TextResource createTextResource( final File file ) {
925
    // TODO: Create PlainTextEditor that's returned by default.
926
    return MediaType.valueFrom( file ) == TEXT_YAML
927
      ? createDefinitionEditor( file )
928
      : createMarkdownEditor( file );
929
  }
930
931
  /**
932
   * Creates an instance of {@link MarkdownEditor} that listens for both
933
   * caret change events and text change events. Text change events must
934
   * take priority over caret change events because it's possible to change
935
   * the text without moving the caret (e.g., delete selected text).
936
   *
937
   * @param file The file containing contents for the text editor.
938
   * @return A non-null text editor.
939
   */
940
  private TextResource createMarkdownEditor( final File file ) {
941
    final var path = file.toPath();
942
    final var editor = new MarkdownEditor( file, getWorkspace() );
943
    final var caret = editor.getCaret();
944
    final var context = createProcessorContext( path, caret );
945
946
    mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) );
947
948
    editor.addDirtyListener( ( c, o, n ) -> {
949
      if( n ) {
950
        // Reset the status to OK after changing the text.
951
        clue();
952
953
        // Processing the text may update the status bar.
954
        process( getActiveTextEditor() );
955
      }
956
    } );
957
958
    editor.addEventListener(
959
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
960
    );
961
962
    // Set the active editor, which refreshes the preview panel.
963
    mActiveTextEditor.set( editor );
964
965
    return editor;
966
  }
967
968
  /**
969
   * Delegates to {@link #autoinsert()}.
970
   *
971
   * @param event Ignored.
972
   */
973
  private void autoinsert( final KeyEvent event ) {
974
    autoinsert();
975
  }
976
977
  /**
978
   * Finds a node that matches the word at the caret, then inserts the
979
   * corresponding definition. The definition token delimiters depend on
980
   * the type of file being edited.
981
   */
982
  public void autoinsert() {
983
    final var definitions = getActiveTextDefinition();
984
    final var editor = getActiveTextEditor();
985
    final var mediaType = editor.getMediaType();
986
    final var operator = getSigilOperator( mediaType );
987
988
    DefinitionNameInjector.autoinsert( editor, definitions, operator );
989
  }
990
991
  private TextDefinition createDefinitionEditor() {
992
    return createDefinitionEditor( DEFINITION_DEFAULT );
993
  }
994
995
  private TextDefinition createDefinitionEditor( final File file ) {
996
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
997
    editor.addTreeChangeHandler( mTreeHandler );
998
    return editor;
999
  }
1000
1001
  private TreeTransformer createTreeTransformer() {
1002
    return new YamlTreeTransformer();
1003
  }
1004
1005
  private Tooltip createTooltip( final File file ) {
1006
    final var path = file.toPath();
1007
    final var tooltip = new Tooltip( path.toString() );
1008
1009
    tooltip.setShowDelay( millis( 200 ) );
1010
    return tooltip;
1011
  }
1012
1013
  public TextEditor getActiveTextEditor() {
1014
    return mActiveTextEditor.get();
1015
  }
1016
1017
  public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() {
1018
    return mActiveTextEditor;
1019
  }
1020
1021
  public TextDefinition getActiveTextDefinition() {
1022
    return mActiveDefinitionEditor.get();
1023
  }
1024
1025
  public Window getWindow() {
1026
    return getScene().getWindow();
1027
  }
1028
1029
  public Workspace getWorkspace() {
1030
    return mWorkspace;
1031
  }
1032
1033
  /**
1034
   * Returns the sigil operator for the given {@link MediaType}.
1035
   *
1036
   * @param mediaType The type of file being edited.
1037
   */
1038
  private SigilOperator getSigilOperator( final MediaType mediaType ) {
1039
    final var operator = new YamlSigilOperator( createDefinitionTokens() );
1040
1041
    return mediaType == TEXT_R_MARKDOWN
1042
      ? new RSigilOperator( createRTokens(), operator )
1043
      : operator;
10381044
  }
10391045
M src/main/java/com/keenwrite/ScrollEventHandler.java
170170
171171
    for( final var node : pane.getChildrenUnmodifiable() ) {
172
      if( node instanceof ScrollBar ) {
173
        final var scrollBar = (ScrollBar) node;
174
175
        if( scrollBar.getOrientation() == VERTICAL ) {
176
          return scrollBar;
177
        }
172
      if( node instanceof final ScrollBar scrollBar &&
173
        scrollBar.getOrientation() == VERTICAL ) {
174
        return scrollBar;
178175
      }
179176
    }
M src/main/java/com/keenwrite/editors/TextResource.java
228228
      : forName( charset.toUpperCase( ENGLISH ) );
229229
  }
230
231
  /**
232
   * Answers whether the given resource are of the same conceptual type. This
233
   * method is intended to be overridden by subclasses.
234
   *
235
   * @param mediaType The type to compare.
236
   * @return {@code true} if the {@link TextResource} is compatible with the
237
   * given {@link MediaType}.
238
   */
239
  default boolean supports( final MediaType mediaType ) {
240
    return isMediaType( mediaType );
241
  }
230242
}
231243
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
55
import com.keenwrite.constants.Constants;
66
import com.keenwrite.editors.TextEditor;
7
import com.keenwrite.preferences.LocaleProperty;
8
import com.keenwrite.preferences.Workspace;
9
import com.keenwrite.spelling.impl.TextEditorSpeller;
10
import javafx.beans.binding.Bindings;
11
import javafx.beans.property.*;
12
import javafx.beans.value.ChangeListener;
13
import javafx.event.Event;
14
import javafx.scene.Node;
15
import javafx.scene.control.IndexRange;
16
import javafx.scene.input.KeyEvent;
17
import javafx.scene.layout.BorderPane;
18
import org.fxmisc.flowless.VirtualizedScrollPane;
19
import org.fxmisc.richtext.StyleClassedTextArea;
20
import org.fxmisc.richtext.model.StyleSpans;
21
import org.fxmisc.undo.UndoManager;
22
import org.fxmisc.wellbehaved.event.EventPattern;
23
import org.fxmisc.wellbehaved.event.Nodes;
24
25
import java.io.File;
26
import java.nio.charset.Charset;
27
import java.text.BreakIterator;
28
import java.util.*;
29
import java.util.function.Consumer;
30
import java.util.function.Supplier;
31
import java.util.regex.Pattern;
32
33
import static com.keenwrite.constants.Constants.*;
34
import static com.keenwrite.MainApp.keyDown;
35
import static com.keenwrite.Messages.get;
36
import static com.keenwrite.events.StatusEvent.clue;
37
import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus;
38
import static com.keenwrite.preferences.WorkspaceKeys.*;
39
import static java.lang.Character.isWhitespace;
40
import static java.lang.String.format;
41
import static java.util.Collections.singletonList;
42
import static javafx.application.Platform.runLater;
43
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
44
import static javafx.scene.input.KeyCode.*;
45
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
46
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
47
import static org.apache.commons.lang3.StringUtils.stripEnd;
48
import static org.apache.commons.lang3.StringUtils.stripStart;
49
import static org.fxmisc.richtext.model.StyleSpans.singleton;
50
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
51
import static org.fxmisc.wellbehaved.event.InputMap.consume;
52
53
/**
54
 * Responsible for editing Markdown documents.
55
 */
56
public final class MarkdownEditor extends BorderPane implements TextEditor {
57
  /**
58
   * Regular expression that matches the type of markup block. This is used
59
   * when Enter is pressed to continue the block environment.
60
   */
61
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
62
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
63
64
  /**
65
   * The text editor.
66
   */
67
  private final StyleClassedTextArea mTextArea =
68
    new StyleClassedTextArea( false );
69
70
  /**
71
   * Wraps the text editor in scrollbars.
72
   */
73
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
74
    new VirtualizedScrollPane<>( mTextArea );
75
76
  private final Workspace mWorkspace;
77
78
  /**
79
   * Tracks where the caret is located in this document. This offers observable
80
   * properties for caret position changes.
81
   */
82
  private final Caret mCaret = createCaret( mTextArea );
83
84
  /**
85
   * File being edited by this editor instance.
86
   */
87
  private File mFile;
88
89
  /**
90
   * Set to {@code true} upon text or caret position changes. Value is {@code
91
   * false} by default.
92
   */
93
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
94
95
  /**
96
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
97
   * either no encoding could be determined or this is a new (empty) file.
98
   */
99
  private final Charset mEncoding;
100
101
  /**
102
   * Tracks whether the in-memory definitions have changed with respect to the
103
   * persisted definitions.
104
   */
105
  private final BooleanProperty mModified = new SimpleBooleanProperty();
106
107
  public MarkdownEditor( final Workspace workspace ) {
108
    this( DOCUMENT_DEFAULT, workspace );
109
  }
110
111
  public MarkdownEditor( final File file, final Workspace workspace ) {
112
    mEncoding = open( mFile = file );
113
    mWorkspace = workspace;
114
115
    initTextArea( mTextArea );
116
    initStyle( mTextArea );
117
    initScrollPane( mScrollPane );
118
    initSpellchecker( mTextArea );
119
    initHotKeys();
120
    initUndoManager();
121
  }
122
123
  private void initTextArea( final StyleClassedTextArea textArea ) {
124
    textArea.setWrapText( true );
125
    textArea.requestFollowCaret();
126
    textArea.moveTo( 0 );
127
128
    textArea.textProperty().addListener( ( c, o, n ) -> {
129
      // Fire, regardless of whether the caret position has changed.
130
      mDirty.set( false );
131
132
      // Prevent a caret position change from raising the dirty bits.
133
      mDirty.set( true );
134
    } );
135
136
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
137
      // Fire when the caret position has changed and the text has not.
138
      mDirty.set( true );
139
      mDirty.set( false );
140
    } );
141
142
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
143
      if( n != null && n ) {
144
        fireTextEditorFocus( this );
145
      }
146
    } );
147
  }
148
149
  private void initStyle( final StyleClassedTextArea textArea ) {
150
    textArea.getStyleClass().add( "markdown" );
151
152
    final var stylesheets = textArea.getStylesheets();
153
    stylesheets.add( getStylesheetPath( getLocale() ) );
154
155
    localeProperty().addListener( ( c, o, n ) -> {
156
      if( n != null ) {
157
        stylesheets.clear();
158
        stylesheets.add( getStylesheetPath( getLocale() ) );
159
      }
160
    } );
161
162
    fontNameProperty().addListener(
163
      ( c, o, n ) ->
164
        setFont( mTextArea, getFontName(), getFontSize() )
165
    );
166
167
    fontSizeProperty().addListener(
168
      ( c, o, n ) ->
169
        setFont( mTextArea, getFontName(), getFontSize() )
170
    );
171
172
    setFont( mTextArea, getFontName(), getFontSize() );
173
  }
174
175
  private void initScrollPane(
176
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
177
    scrollpane.setVbarPolicy( ALWAYS );
178
    setCenter( scrollpane );
179
  }
180
181
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
182
    final var speller = new TextEditorSpeller();
183
    speller.checkDocument( textarea );
184
    speller.checkParagraphs( textarea );
185
  }
186
187
  private void initHotKeys() {
188
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
189
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
190
    addEventListener( keyPressed( TAB ), this::tab );
191
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
192
    addEventListener( keyPressed( INSERT ), this::onInsertPressed );
193
  }
194
195
  private void initUndoManager() {
196
    final var undoManager = getUndoManager();
197
    final var markedPosition = undoManager.atMarkedPositionProperty();
198
199
    undoManager.forgetHistory();
200
    undoManager.mark();
201
    mModified.bind( Bindings.not( markedPosition ) );
202
  }
203
204
  @Override
205
  public void moveTo( final int offset ) {
206
    assert 0 <= offset && offset <= mTextArea.getLength();
207
    mTextArea.moveTo( offset );
208
    mTextArea.requestFollowCaret();
209
  }
210
211
  /**
212
   * Delegate the focus request to the text area itself.
213
   */
214
  @Override
215
  public void requestFocus() {
216
    mTextArea.requestFocus();
217
  }
218
219
  @Override
220
  public void setText( final String text ) {
221
    mTextArea.clear();
222
    mTextArea.appendText( text );
223
    mTextArea.getUndoManager().mark();
224
  }
225
226
  @Override
227
  public String getText() {
228
    return mTextArea.getText();
229
  }
230
231
  @Override
232
  public Charset getEncoding() {
233
    return mEncoding;
234
  }
235
236
  @Override
237
  public File getFile() {
238
    return mFile;
239
  }
240
241
  @Override
242
  public void rename( final File file ) {
243
    mFile = file;
244
  }
245
246
  @Override
247
  public void undo() {
248
    final var manager = getUndoManager();
249
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
250
  }
251
252
  @Override
253
  public void redo() {
254
    final var manager = getUndoManager();
255
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
256
  }
257
258
  /**
259
   * Performs an undo or redo action, if possible, otherwise displays an error
260
   * message to the user.
261
   *
262
   * @param ready  Answers whether the action can be executed.
263
   * @param action The action to execute.
264
   * @param key    The informational message key having a value to display if
265
   *               the {@link Supplier} is not ready.
266
   */
267
  private void xxdo(
268
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
269
    if( ready.get() ) {
270
      action.run();
271
    }
272
    else {
273
      clue( key );
274
    }
275
  }
276
277
  @Override
278
  public void cut() {
279
    final var selected = mTextArea.getSelectedText();
280
281
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
282
    if( selected == null || selected.isEmpty() ) {
283
      // Note: mTextArea.selectLine() does not select empty lines.
284
      mTextArea.fireEvent( keyDown( HOME, false ) );
285
      mTextArea.fireEvent( keyDown( DOWN, true ) );
286
    }
287
288
    mTextArea.cut();
289
  }
290
291
  @Override
292
  public void copy() {
293
    mTextArea.copy();
294
  }
295
296
  @Override
297
  public void paste() {
298
    mTextArea.paste();
299
  }
300
301
  @Override
302
  public void selectAll() {
303
    mTextArea.selectAll();
304
  }
305
306
  @Override
307
  public void bold() {
308
    enwrap( "**" );
309
  }
310
311
  @Override
312
  public void italic() {
313
    enwrap( "*" );
314
  }
315
316
  @Override
317
  public void monospace() {
318
    enwrap( "`" );
319
  }
320
321
  @Override
322
  public void superscript() {
323
    enwrap( "^" );
324
  }
325
326
  @Override
327
  public void subscript() {
328
    enwrap( "~" );
329
  }
330
331
  @Override
332
  public void strikethrough() {
333
    enwrap( "~~" );
334
  }
335
336
  @Override
337
  public void blockquote() {
338
    block( "> " );
339
  }
340
341
  @Override
342
  public void code() {
343
    enwrap( "`" );
344
  }
345
346
  @Override
347
  public void fencedCodeBlock() {
348
    enwrap( "\n\n```\n", "\n```\n\n" );
349
  }
350
351
  @Override
352
  public void heading( final int level ) {
353
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
354
    block( format( "%s ", hashes ) );
355
  }
356
357
  @Override
358
  public void unorderedList() {
359
    block( "* " );
360
  }
361
362
  @Override
363
  public void orderedList() {
364
    block( "1. " );
365
  }
366
367
  @Override
368
  public void horizontalRule() {
369
    block( format( "---%n%n" ) );
370
  }
371
372
  @Override
373
  public Node getNode() {
374
    return this;
375
  }
376
377
  @Override
378
  public ReadOnlyBooleanProperty modifiedProperty() {
379
    return mModified;
380
  }
381
382
  @Override
383
  public void clearModifiedProperty() {
384
    getUndoManager().mark();
385
  }
386
387
  @Override
388
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
389
    return mScrollPane;
390
  }
391
392
  @Override
393
  public StyleClassedTextArea getTextArea() {
394
    return mTextArea;
395
  }
396
397
  private final Map<String, IndexRange> mStyles = new HashMap<>();
398
399
  @Override
400
  public void stylize( final IndexRange range, final String style ) {
401
    final var began = range.getStart();
402
    final var ended = range.getEnd() + 1;
403
404
    assert 0 <= began && began <= ended;
405
    assert style != null;
406
407
    // TODO: Ensure spell check and find highlights can coexist.
408
//    final var spans = mTextArea.getStyleSpans( range );
409
//    System.out.println( "SPANS: " + spans );
410
411
//    final var spans = mTextArea.getStyleSpans( range );
412
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
413
//    ) );
414
415
//    final var builder = new StyleSpansBuilder<Collection<String>>();
416
//    builder.add( singleton( style ), range.getLength() + 1 );
417
//    mTextArea.setStyleSpans( began, builder.create() );
418
419
//    final var s = mTextArea.getStyleSpans( began, ended );
420
//    System.out.println( "STYLES: " +s );
421
422
    mStyles.put( style, range );
423
    mTextArea.setStyleClass( began, ended, style );
424
425
    // Ensure that whenever the user interacts with the text that the found
426
    // word will have its highlighting removed. The handler removes itself.
427
    // This won't remove the highlighting if the caret position moves by mouse.
428
    final var handler = mTextArea.getOnKeyPressed();
429
    mTextArea.setOnKeyPressed( ( event ) -> {
430
      mTextArea.setOnKeyPressed( handler );
431
      unstylize( style );
432
    } );
433
434
    //mTextArea.setStyleSpans(began, ended, s);
435
  }
436
437
  private static StyleSpans<Collection<String>> merge(
438
    StyleSpans<Collection<String>> spans, int len, String style ) {
439
    spans = spans.overlay(
440
      singleton( singletonList( style ), len ),
441
      ( bottomSpan, list ) -> {
442
        final List<String> l =
443
          new ArrayList<>( bottomSpan.size() + list.size() );
444
        l.addAll( bottomSpan );
445
        l.addAll( list );
446
        return l;
447
      } );
448
449
    return spans;
450
  }
451
452
  @Override
453
  public void unstylize( final String style ) {
454
    final var indexes = mStyles.remove( style );
455
    if( indexes != null ) {
456
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
457
    }
458
  }
459
460
  @Override
461
  public Caret getCaret() {
462
    return mCaret;
463
  }
464
465
  private Caret createCaret( final StyleClassedTextArea editor ) {
466
    return Caret
467
      .builder()
468
      .with( Caret.Mutator::setEditor, editor )
469
      .build();
470
  }
471
472
  /**
473
   * This method adds listeners to editor events.
474
   *
475
   * @param <T>      The event type.
476
   * @param <U>      The consumer type for the given event type.
477
   * @param event    The event of interest.
478
   * @param consumer The method to call when the event happens.
479
   */
480
  public <T extends Event, U extends T> void addEventListener(
481
    final EventPattern<? super T, ? extends U> event,
482
    final Consumer<? super U> consumer ) {
483
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
484
  }
485
486
  private void onEnterPressed( final KeyEvent ignored ) {
487
    final var currentLine = getCaretParagraph();
488
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
489
490
    // By default, insert a new line by itself.
491
    String newText = NEWLINE;
492
493
    // If the pattern was matched then determine what block type to continue.
494
    if( matcher.matches() ) {
495
      if( matcher.group( 2 ).isEmpty() ) {
496
        final var pos = mTextArea.getCaretPosition();
497
        mTextArea.selectRange( pos - currentLine.length(), pos );
498
      }
499
      else {
500
        // Indent the new line with the same whitespace characters and
501
        // list markers as current line. This ensures that the indentation
502
        // is propagated.
503
        newText = newText.concat( matcher.group( 1 ) );
504
      }
505
    }
506
507
    mTextArea.replaceSelection( newText );
508
  }
509
510
  /**
511
   * TODO: 105 - Insert key toggle overwrite (typeover) mode
512
   *
513
   * @param ignored Unused.
514
   */
515
  private void onInsertPressed( final KeyEvent ignored ) {
516
  }
517
518
  private void cut( final KeyEvent event ) {
519
    cut();
520
  }
521
522
  private void tab( final KeyEvent event ) {
523
    final var range = mTextArea.selectionProperty().getValue();
524
    final var sb = new StringBuilder( 1024 );
525
526
    if( range.getLength() > 0 ) {
527
      final var selection = mTextArea.getSelectedText();
528
529
      selection.lines().forEach(
530
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
531
      );
532
    }
533
    else {
534
      sb.append( "\t" );
535
    }
536
537
    mTextArea.replaceSelection( sb.toString() );
538
  }
539
540
  private void untab( final KeyEvent event ) {
541
    final var range = mTextArea.selectionProperty().getValue();
542
543
    if( range.getLength() > 0 ) {
544
      final var selection = mTextArea.getSelectedText();
545
      final var sb = new StringBuilder( selection.length() );
546
547
      selection.lines().forEach(
548
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
549
                   .append( NEWLINE )
550
      );
551
552
      mTextArea.replaceSelection( sb.toString() );
553
    }
554
    else {
555
      final var p = getCaretParagraph();
556
557
      if( p.startsWith( "\t" ) ) {
558
        mTextArea.selectParagraph();
559
        mTextArea.replaceSelection( p.substring( 1 ) );
560
      }
561
    }
562
  }
563
564
  /**
565
   * Observers may listen for changes to the property returned from this method
566
   * to receive notifications when either the text or caret have changed. This
567
   * should not be used to track whether the text has been modified.
568
   */
569
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
570
    mDirty.addListener( listener );
571
  }
572
573
  /**
574
   * Surrounds the selected text or word under the caret in Markdown markup.
575
   *
576
   * @param token The beginning and ending token for enclosing the text.
577
   */
578
  private void enwrap( final String token ) {
579
    enwrap( token, token );
580
  }
581
582
  /**
583
   * Surrounds the selected text or word under the caret in Markdown markup.
584
   *
585
   * @param began The beginning token for enclosing the text.
586
   * @param ended The ending token for enclosing the text.
587
   */
588
  private void enwrap( final String began, String ended ) {
589
    // Ensure selected text takes precedence over the word at caret position.
590
    final var selected = mTextArea.selectionProperty().getValue();
591
    final var range = selected.getLength() == 0
592
      ? getCaretWord()
593
      : selected;
594
    String text = mTextArea.getText( range );
595
596
    int length = range.getLength();
597
    text = stripStart( text, null );
598
    final int beganIndex = range.getStart() + (length - text.length());
599
600
    length = text.length();
601
    text = stripEnd( text, null );
602
    final int endedIndex = range.getEnd() - (length - text.length());
603
604
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
605
  }
606
607
  /**
608
   * Inserts the given block-level markup at the current caret position
609
   * within the document. This will prepend two blank lines to ensure that
610
   * the block element begins at the start of a new line.
611
   *
612
   * @param markup The text to insert at the caret.
613
   */
614
  private void block( final String markup ) {
615
    final int pos = mTextArea.getCaretPosition();
616
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
617
  }
618
619
  /**
620
   * Returns the caret position within the current paragraph.
621
   *
622
   * @return A value from 0 to the length of the current paragraph.
623
   */
624
  private int getCaretColumn() {
625
    return mTextArea.getCaretColumn();
626
  }
627
628
  @Override
629
  public IndexRange getCaretWord() {
630
    final var paragraph = getCaretParagraph();
631
    final var length = paragraph.length();
632
    final var column = getCaretColumn();
633
634
    var began = column;
635
    var ended = column;
636
637
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
638
      began--;
639
    }
640
641
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
642
      ended++;
643
    }
644
645
    final var iterator = BreakIterator.getWordInstance();
646
    iterator.setText( paragraph );
647
648
    while( began < length && iterator.isBoundary( began + 1 ) ) {
649
      began++;
650
    }
651
652
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
653
      ended--;
654
    }
655
656
    final var offset = getCaretDocumentOffset( column );
657
658
    return IndexRange.normalize( began + offset, ended + offset );
659
  }
660
661
  private int getCaretDocumentOffset( final int column ) {
662
    return mTextArea.getCaretPosition() - column;
663
  }
664
665
  /**
666
   * Returns the index of the paragraph where the caret resides.
667
   *
668
   * @return A number greater than or equal to 0.
669
   */
670
  private int getCurrentParagraph() {
671
    return mTextArea.getCurrentParagraph();
672
  }
673
674
  /**
675
   * Returns the text for the paragraph that contains the caret.
676
   *
677
   * @return A non-null string, possibly empty.
678
   */
679
  private String getCaretParagraph() {
680
    return getText( getCurrentParagraph() );
681
  }
682
683
  @Override
684
  public String getText( final int paragraph ) {
685
    return mTextArea.getText( paragraph );
686
  }
687
688
  @Override
689
  public String getText( final IndexRange indexes )
690
    throws IndexOutOfBoundsException {
691
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
692
  }
693
694
  @Override
695
  public void replaceText( final IndexRange indexes, final String s ) {
696
    mTextArea.replaceText( indexes, s );
697
  }
698
699
  private UndoManager<?> getUndoManager() {
700
    return mTextArea.getUndoManager();
701
  }
702
703
  /**
704
   * Returns the path to a {@link Locale}-specific stylesheet.
705
   *
706
   * @return A non-null string to inject into the HTML document head.
707
   */
708
  private static String getStylesheetPath( final Locale locale ) {
709
    return get(
710
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
711
      locale.getLanguage(),
712
      locale.getScript(),
713
      locale.getCountry()
714
    );
715
  }
716
717
  private Locale getLocale() {
718
    return localeProperty().toLocale();
719
  }
720
721
  private LocaleProperty localeProperty() {
722
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
723
  }
724
725
  /**
726
   * Sets the font family name and font size at the same time. When the
727
   * workspace is loaded, the default font values are changed, which results
728
   * in this method being called.
729
   *
730
   * @param area Change the font settings for this text area.
731
   * @param name New font family name to apply.
732
   * @param points New font size to apply (in points, not pixels).
733
   */
734
  private void setFont(
735
    final StyleClassedTextArea area, final String name, final double points ) {
736
    runLater( () -> area.setStyle(
737
      format(
738
        "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points )
739
      )
740
    ) );
741
  }
742
743
  private String getFontName() {
744
    return fontNameProperty().get();
745
  }
746
747
  private StringProperty fontNameProperty() {
748
    return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
749
  }
750
751
  private double getFontSize() {
752
    return fontSizeProperty().get();
753
  }
754
755
  private DoubleProperty fontSizeProperty() {
756
    return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE );
7
import com.keenwrite.io.MediaType;
8
import com.keenwrite.preferences.LocaleProperty;
9
import com.keenwrite.preferences.Workspace;
10
import com.keenwrite.spelling.impl.TextEditorSpeller;
11
import javafx.beans.binding.Bindings;
12
import javafx.beans.property.*;
13
import javafx.beans.value.ChangeListener;
14
import javafx.event.Event;
15
import javafx.scene.Node;
16
import javafx.scene.control.IndexRange;
17
import javafx.scene.input.KeyEvent;
18
import javafx.scene.layout.BorderPane;
19
import org.fxmisc.flowless.VirtualizedScrollPane;
20
import org.fxmisc.richtext.StyleClassedTextArea;
21
import org.fxmisc.richtext.model.StyleSpans;
22
import org.fxmisc.undo.UndoManager;
23
import org.fxmisc.wellbehaved.event.EventPattern;
24
import org.fxmisc.wellbehaved.event.Nodes;
25
26
import java.io.File;
27
import java.nio.charset.Charset;
28
import java.text.BreakIterator;
29
import java.util.*;
30
import java.util.function.Consumer;
31
import java.util.function.Supplier;
32
import java.util.regex.Pattern;
33
34
import static com.keenwrite.MainApp.keyDown;
35
import static com.keenwrite.Messages.get;
36
import static com.keenwrite.constants.Constants.*;
37
import static com.keenwrite.events.StatusEvent.clue;
38
import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus;
39
import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
40
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
41
import static com.keenwrite.preferences.WorkspaceKeys.*;
42
import static java.lang.Character.isWhitespace;
43
import static java.lang.String.format;
44
import static java.util.Collections.singletonList;
45
import static javafx.application.Platform.runLater;
46
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
47
import static javafx.scene.input.KeyCode.*;
48
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
49
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
50
import static org.apache.commons.lang3.StringUtils.stripEnd;
51
import static org.apache.commons.lang3.StringUtils.stripStart;
52
import static org.fxmisc.richtext.model.StyleSpans.singleton;
53
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
54
import static org.fxmisc.wellbehaved.event.InputMap.consume;
55
56
/**
57
 * Responsible for editing Markdown documents.
58
 */
59
public final class MarkdownEditor extends BorderPane implements TextEditor {
60
  /**
61
   * Regular expression that matches the type of markup block. This is used
62
   * when Enter is pressed to continue the block environment.
63
   */
64
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
65
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
66
67
  /**
68
   * The text editor.
69
   */
70
  private final StyleClassedTextArea mTextArea =
71
    new StyleClassedTextArea( false );
72
73
  /**
74
   * Wraps the text editor in scrollbars.
75
   */
76
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
77
    new VirtualizedScrollPane<>( mTextArea );
78
79
  private final Workspace mWorkspace;
80
81
  /**
82
   * Tracks where the caret is located in this document. This offers observable
83
   * properties for caret position changes.
84
   */
85
  private final Caret mCaret = createCaret( mTextArea );
86
87
  /**
88
   * File being edited by this editor instance.
89
   */
90
  private File mFile;
91
92
  /**
93
   * Set to {@code true} upon text or caret position changes. Value is {@code
94
   * false} by default.
95
   */
96
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
97
98
  /**
99
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
100
   * either no encoding could be determined or this is a new (empty) file.
101
   */
102
  private final Charset mEncoding;
103
104
  /**
105
   * Tracks whether the in-memory definitions have changed with respect to the
106
   * persisted definitions.
107
   */
108
  private final BooleanProperty mModified = new SimpleBooleanProperty();
109
110
  public MarkdownEditor( final Workspace workspace ) {
111
    this( DOCUMENT_DEFAULT, workspace );
112
  }
113
114
  public MarkdownEditor( final File file, final Workspace workspace ) {
115
    mEncoding = open( mFile = file );
116
    mWorkspace = workspace;
117
118
    initTextArea( mTextArea );
119
    initStyle( mTextArea );
120
    initScrollPane( mScrollPane );
121
    initSpellchecker( mTextArea );
122
    initHotKeys();
123
    initUndoManager();
124
  }
125
126
  private void initTextArea( final StyleClassedTextArea textArea ) {
127
    textArea.setWrapText( true );
128
    textArea.requestFollowCaret();
129
    textArea.moveTo( 0 );
130
131
    textArea.textProperty().addListener( ( c, o, n ) -> {
132
      // Fire, regardless of whether the caret position has changed.
133
      mDirty.set( false );
134
135
      // Prevent a caret position change from raising the dirty bits.
136
      mDirty.set( true );
137
    } );
138
139
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
140
      // Fire when the caret position has changed and the text has not.
141
      mDirty.set( true );
142
      mDirty.set( false );
143
    } );
144
145
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
146
      if( n != null && n ) {
147
        fireTextEditorFocus( this );
148
      }
149
    } );
150
  }
151
152
  private void initStyle( final StyleClassedTextArea textArea ) {
153
    textArea.getStyleClass().add( "markdown" );
154
155
    final var stylesheets = textArea.getStylesheets();
156
    stylesheets.add( getStylesheetPath( getLocale() ) );
157
158
    localeProperty().addListener( ( c, o, n ) -> {
159
      if( n != null ) {
160
        stylesheets.clear();
161
        stylesheets.add( getStylesheetPath( getLocale() ) );
162
      }
163
    } );
164
165
    fontNameProperty().addListener(
166
      ( c, o, n ) ->
167
        setFont( mTextArea, getFontName(), getFontSize() )
168
    );
169
170
    fontSizeProperty().addListener(
171
      ( c, o, n ) ->
172
        setFont( mTextArea, getFontName(), getFontSize() )
173
    );
174
175
    setFont( mTextArea, getFontName(), getFontSize() );
176
  }
177
178
  private void initScrollPane(
179
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
180
    scrollpane.setVbarPolicy( ALWAYS );
181
    setCenter( scrollpane );
182
  }
183
184
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
185
    final var speller = new TextEditorSpeller();
186
    speller.checkDocument( textarea );
187
    speller.checkParagraphs( textarea );
188
  }
189
190
  private void initHotKeys() {
191
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
192
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
193
    addEventListener( keyPressed( TAB ), this::tab );
194
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
195
    addEventListener( keyPressed( INSERT ), this::onInsertPressed );
196
  }
197
198
  private void initUndoManager() {
199
    final var undoManager = getUndoManager();
200
    final var markedPosition = undoManager.atMarkedPositionProperty();
201
202
    undoManager.forgetHistory();
203
    undoManager.mark();
204
    mModified.bind( Bindings.not( markedPosition ) );
205
  }
206
207
  @Override
208
  public void moveTo( final int offset ) {
209
    assert 0 <= offset && offset <= mTextArea.getLength();
210
    mTextArea.moveTo( offset );
211
    mTextArea.requestFollowCaret();
212
  }
213
214
  /**
215
   * Delegate the focus request to the text area itself.
216
   */
217
  @Override
218
  public void requestFocus() {
219
    mTextArea.requestFocus();
220
  }
221
222
  @Override
223
  public void setText( final String text ) {
224
    mTextArea.clear();
225
    mTextArea.appendText( text );
226
    mTextArea.getUndoManager().mark();
227
  }
228
229
  @Override
230
  public String getText() {
231
    return mTextArea.getText();
232
  }
233
234
  @Override
235
  public Charset getEncoding() {
236
    return mEncoding;
237
  }
238
239
  @Override
240
  public File getFile() {
241
    return mFile;
242
  }
243
244
  @Override
245
  public void rename( final File file ) {
246
    mFile = file;
247
  }
248
249
  @Override
250
  public void undo() {
251
    final var manager = getUndoManager();
252
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
253
  }
254
255
  @Override
256
  public void redo() {
257
    final var manager = getUndoManager();
258
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
259
  }
260
261
  /**
262
   * Performs an undo or redo action, if possible, otherwise displays an error
263
   * message to the user.
264
   *
265
   * @param ready  Answers whether the action can be executed.
266
   * @param action The action to execute.
267
   * @param key    The informational message key having a value to display if
268
   *               the {@link Supplier} is not ready.
269
   */
270
  private void xxdo(
271
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
272
    if( ready.get() ) {
273
      action.run();
274
    }
275
    else {
276
      clue( key );
277
    }
278
  }
279
280
  @Override
281
  public void cut() {
282
    final var selected = mTextArea.getSelectedText();
283
284
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
285
    if( selected == null || selected.isEmpty() ) {
286
      // Note: mTextArea.selectLine() does not select empty lines.
287
      mTextArea.fireEvent( keyDown( HOME, false ) );
288
      mTextArea.fireEvent( keyDown( DOWN, true ) );
289
    }
290
291
    mTextArea.cut();
292
  }
293
294
  @Override
295
  public void copy() {
296
    mTextArea.copy();
297
  }
298
299
  @Override
300
  public void paste() {
301
    mTextArea.paste();
302
  }
303
304
  @Override
305
  public void selectAll() {
306
    mTextArea.selectAll();
307
  }
308
309
  @Override
310
  public void bold() {
311
    enwrap( "**" );
312
  }
313
314
  @Override
315
  public void italic() {
316
    enwrap( "*" );
317
  }
318
319
  @Override
320
  public void monospace() {
321
    enwrap( "`" );
322
  }
323
324
  @Override
325
  public void superscript() {
326
    enwrap( "^" );
327
  }
328
329
  @Override
330
  public void subscript() {
331
    enwrap( "~" );
332
  }
333
334
  @Override
335
  public void strikethrough() {
336
    enwrap( "~~" );
337
  }
338
339
  @Override
340
  public void blockquote() {
341
    block( "> " );
342
  }
343
344
  @Override
345
  public void code() {
346
    enwrap( "`" );
347
  }
348
349
  @Override
350
  public void fencedCodeBlock() {
351
    enwrap( "\n\n```\n", "\n```\n\n" );
352
  }
353
354
  @Override
355
  public void heading( final int level ) {
356
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
357
    block( format( "%s ", hashes ) );
358
  }
359
360
  @Override
361
  public void unorderedList() {
362
    block( "* " );
363
  }
364
365
  @Override
366
  public void orderedList() {
367
    block( "1. " );
368
  }
369
370
  @Override
371
  public void horizontalRule() {
372
    block( format( "---%n%n" ) );
373
  }
374
375
  @Override
376
  public Node getNode() {
377
    return this;
378
  }
379
380
  @Override
381
  public ReadOnlyBooleanProperty modifiedProperty() {
382
    return mModified;
383
  }
384
385
  @Override
386
  public void clearModifiedProperty() {
387
    getUndoManager().mark();
388
  }
389
390
  @Override
391
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
392
    return mScrollPane;
393
  }
394
395
  @Override
396
  public StyleClassedTextArea getTextArea() {
397
    return mTextArea;
398
  }
399
400
  private final Map<String, IndexRange> mStyles = new HashMap<>();
401
402
  @Override
403
  public void stylize( final IndexRange range, final String style ) {
404
    final var began = range.getStart();
405
    final var ended = range.getEnd() + 1;
406
407
    assert 0 <= began && began <= ended;
408
    assert style != null;
409
410
    // TODO: Ensure spell check and find highlights can coexist.
411
//    final var spans = mTextArea.getStyleSpans( range );
412
//    System.out.println( "SPANS: " + spans );
413
414
//    final var spans = mTextArea.getStyleSpans( range );
415
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
416
//    ) );
417
418
//    final var builder = new StyleSpansBuilder<Collection<String>>();
419
//    builder.add( singleton( style ), range.getLength() + 1 );
420
//    mTextArea.setStyleSpans( began, builder.create() );
421
422
//    final var s = mTextArea.getStyleSpans( began, ended );
423
//    System.out.println( "STYLES: " +s );
424
425
    mStyles.put( style, range );
426
    mTextArea.setStyleClass( began, ended, style );
427
428
    // Ensure that whenever the user interacts with the text that the found
429
    // word will have its highlighting removed. The handler removes itself.
430
    // This won't remove the highlighting if the caret position moves by mouse.
431
    final var handler = mTextArea.getOnKeyPressed();
432
    mTextArea.setOnKeyPressed( ( event ) -> {
433
      mTextArea.setOnKeyPressed( handler );
434
      unstylize( style );
435
    } );
436
437
    //mTextArea.setStyleSpans(began, ended, s);
438
  }
439
440
  private static StyleSpans<Collection<String>> merge(
441
    StyleSpans<Collection<String>> spans, int len, String style ) {
442
    spans = spans.overlay(
443
      singleton( singletonList( style ), len ),
444
      ( bottomSpan, list ) -> {
445
        final List<String> l =
446
          new ArrayList<>( bottomSpan.size() + list.size() );
447
        l.addAll( bottomSpan );
448
        l.addAll( list );
449
        return l;
450
      } );
451
452
    return spans;
453
  }
454
455
  @Override
456
  public void unstylize( final String style ) {
457
    final var indexes = mStyles.remove( style );
458
    if( indexes != null ) {
459
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
460
    }
461
  }
462
463
  @Override
464
  public Caret getCaret() {
465
    return mCaret;
466
  }
467
468
  private Caret createCaret( final StyleClassedTextArea editor ) {
469
    return Caret
470
      .builder()
471
      .with( Caret.Mutator::setEditor, editor )
472
      .build();
473
  }
474
475
  /**
476
   * This method adds listeners to editor events.
477
   *
478
   * @param <T>      The event type.
479
   * @param <U>      The consumer type for the given event type.
480
   * @param event    The event of interest.
481
   * @param consumer The method to call when the event happens.
482
   */
483
  public <T extends Event, U extends T> void addEventListener(
484
    final EventPattern<? super T, ? extends U> event,
485
    final Consumer<? super U> consumer ) {
486
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
487
  }
488
489
  private void onEnterPressed( final KeyEvent ignored ) {
490
    final var currentLine = getCaretParagraph();
491
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
492
493
    // By default, insert a new line by itself.
494
    String newText = NEWLINE;
495
496
    // If the pattern was matched then determine what block type to continue.
497
    if( matcher.matches() ) {
498
      if( matcher.group( 2 ).isEmpty() ) {
499
        final var pos = mTextArea.getCaretPosition();
500
        mTextArea.selectRange( pos - currentLine.length(), pos );
501
      }
502
      else {
503
        // Indent the new line with the same whitespace characters and
504
        // list markers as current line. This ensures that the indentation
505
        // is propagated.
506
        newText = newText.concat( matcher.group( 1 ) );
507
      }
508
    }
509
510
    mTextArea.replaceSelection( newText );
511
  }
512
513
  /**
514
   * TODO: 105 - Insert key toggle overwrite (typeover) mode
515
   *
516
   * @param ignored Unused.
517
   */
518
  private void onInsertPressed( final KeyEvent ignored ) {
519
  }
520
521
  private void cut( final KeyEvent event ) {
522
    cut();
523
  }
524
525
  private void tab( final KeyEvent event ) {
526
    final var range = mTextArea.selectionProperty().getValue();
527
    final var sb = new StringBuilder( 1024 );
528
529
    if( range.getLength() > 0 ) {
530
      final var selection = mTextArea.getSelectedText();
531
532
      selection.lines().forEach(
533
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
534
      );
535
    }
536
    else {
537
      sb.append( "\t" );
538
    }
539
540
    mTextArea.replaceSelection( sb.toString() );
541
  }
542
543
  private void untab( final KeyEvent event ) {
544
    final var range = mTextArea.selectionProperty().getValue();
545
546
    if( range.getLength() > 0 ) {
547
      final var selection = mTextArea.getSelectedText();
548
      final var sb = new StringBuilder( selection.length() );
549
550
      selection.lines().forEach(
551
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
552
                   .append( NEWLINE )
553
      );
554
555
      mTextArea.replaceSelection( sb.toString() );
556
    }
557
    else {
558
      final var p = getCaretParagraph();
559
560
      if( p.startsWith( "\t" ) ) {
561
        mTextArea.selectParagraph();
562
        mTextArea.replaceSelection( p.substring( 1 ) );
563
      }
564
    }
565
  }
566
567
  /**
568
   * Observers may listen for changes to the property returned from this method
569
   * to receive notifications when either the text or caret have changed. This
570
   * should not be used to track whether the text has been modified.
571
   */
572
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
573
    mDirty.addListener( listener );
574
  }
575
576
  /**
577
   * Surrounds the selected text or word under the caret in Markdown markup.
578
   *
579
   * @param token The beginning and ending token for enclosing the text.
580
   */
581
  private void enwrap( final String token ) {
582
    enwrap( token, token );
583
  }
584
585
  /**
586
   * Surrounds the selected text or word under the caret in Markdown markup.
587
   *
588
   * @param began The beginning token for enclosing the text.
589
   * @param ended The ending token for enclosing the text.
590
   */
591
  private void enwrap( final String began, String ended ) {
592
    // Ensure selected text takes precedence over the word at caret position.
593
    final var selected = mTextArea.selectionProperty().getValue();
594
    final var range = selected.getLength() == 0
595
      ? getCaretWord()
596
      : selected;
597
    String text = mTextArea.getText( range );
598
599
    int length = range.getLength();
600
    text = stripStart( text, null );
601
    final int beganIndex = range.getStart() + (length - text.length());
602
603
    length = text.length();
604
    text = stripEnd( text, null );
605
    final int endedIndex = range.getEnd() - (length - text.length());
606
607
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
608
  }
609
610
  /**
611
   * Inserts the given block-level markup at the current caret position
612
   * within the document. This will prepend two blank lines to ensure that
613
   * the block element begins at the start of a new line.
614
   *
615
   * @param markup The text to insert at the caret.
616
   */
617
  private void block( final String markup ) {
618
    final int pos = mTextArea.getCaretPosition();
619
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
620
  }
621
622
  /**
623
   * Returns the caret position within the current paragraph.
624
   *
625
   * @return A value from 0 to the length of the current paragraph.
626
   */
627
  private int getCaretColumn() {
628
    return mTextArea.getCaretColumn();
629
  }
630
631
  @Override
632
  public IndexRange getCaretWord() {
633
    final var paragraph = getCaretParagraph();
634
    final var length = paragraph.length();
635
    final var column = getCaretColumn();
636
637
    var began = column;
638
    var ended = column;
639
640
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
641
      began--;
642
    }
643
644
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
645
      ended++;
646
    }
647
648
    final var iterator = BreakIterator.getWordInstance();
649
    iterator.setText( paragraph );
650
651
    while( began < length && iterator.isBoundary( began + 1 ) ) {
652
      began++;
653
    }
654
655
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
656
      ended--;
657
    }
658
659
    final var offset = getCaretDocumentOffset( column );
660
661
    return IndexRange.normalize( began + offset, ended + offset );
662
  }
663
664
  private int getCaretDocumentOffset( final int column ) {
665
    return mTextArea.getCaretPosition() - column;
666
  }
667
668
  /**
669
   * Returns the index of the paragraph where the caret resides.
670
   *
671
   * @return A number greater than or equal to 0.
672
   */
673
  private int getCurrentParagraph() {
674
    return mTextArea.getCurrentParagraph();
675
  }
676
677
  /**
678
   * Returns the text for the paragraph that contains the caret.
679
   *
680
   * @return A non-null string, possibly empty.
681
   */
682
  private String getCaretParagraph() {
683
    return getText( getCurrentParagraph() );
684
  }
685
686
  @Override
687
  public String getText( final int paragraph ) {
688
    return mTextArea.getText( paragraph );
689
  }
690
691
  @Override
692
  public String getText( final IndexRange indexes )
693
    throws IndexOutOfBoundsException {
694
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
695
  }
696
697
  @Override
698
  public void replaceText( final IndexRange indexes, final String s ) {
699
    mTextArea.replaceText( indexes, s );
700
  }
701
702
  private UndoManager<?> getUndoManager() {
703
    return mTextArea.getUndoManager();
704
  }
705
706
  /**
707
   * Returns the path to a {@link Locale}-specific stylesheet.
708
   *
709
   * @return A non-null string to inject into the HTML document head.
710
   */
711
  private static String getStylesheetPath( final Locale locale ) {
712
    return get(
713
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
714
      locale.getLanguage(),
715
      locale.getScript(),
716
      locale.getCountry()
717
    );
718
  }
719
720
  private Locale getLocale() {
721
    return localeProperty().toLocale();
722
  }
723
724
  private LocaleProperty localeProperty() {
725
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
726
  }
727
728
  /**
729
   * Sets the font family name and font size at the same time. When the
730
   * workspace is loaded, the default font values are changed, which results
731
   * in this method being called.
732
   *
733
   * @param area   Change the font settings for this text area.
734
   * @param name   New font family name to apply.
735
   * @param points New font size to apply (in points, not pixels).
736
   */
737
  private void setFont(
738
    final StyleClassedTextArea area, final String name, final double points ) {
739
    runLater( () -> area.setStyle(
740
      format(
741
        "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points )
742
      )
743
    ) );
744
  }
745
746
  private String getFontName() {
747
    return fontNameProperty().get();
748
  }
749
750
  private StringProperty fontNameProperty() {
751
    return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
752
  }
753
754
  private double getFontSize() {
755
    return fontSizeProperty().get();
756
  }
757
758
  private DoubleProperty fontSizeProperty() {
759
    return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE );
760
  }
761
762
  /**
763
   * Answers whether the given resource is of compatible {@link MediaType}s.
764
   *
765
   * @param mediaType The {@link MediaType} to compare.
766
   * @return {@code true} if the given {@link MediaType} is suitable for
767
   * editing with this type of editor.
768
   */
769
  @Override
770
  public boolean supports( final MediaType mediaType ) {
771
    return isMediaType( mediaType ) ||
772
      mediaType == TEXT_MARKDOWN ||
773
      mediaType == TEXT_R_MARKDOWN;
757774
  }
758775
}
M src/main/java/com/keenwrite/io/FileType.java
1414
  SOURCE( "source" ),
1515
  DEFINITION( "definition" ),
16
  XML( "xml" ),
1716
  CSV( "csv" ),
1817
  JSON( "json" ),
M src/main/java/com/keenwrite/io/MediaType.java
104104
  TEXT_PLAIN( TEXT, "plain" ),
105105
  TEXT_R_MARKDOWN( TEXT, "R+markdown" ),
106
  TEXT_R_XML( TEXT, "R+xml" ),
107106
  TEXT_XHTML( TEXT, "xhtml+xml" ),
108107
  TEXT_XML( TEXT, "xml" ),
M src/main/java/com/keenwrite/io/MediaTypeExtension.java
4646
  MEDIA_TEXT_PLAIN( TEXT_PLAIN, of( "txt", "asc", "ascii", "text", "utxt" ) ),
4747
  MEDIA_TEXT_R_MARKDOWN( TEXT_R_MARKDOWN, of( "Rmd" ) ),
48
  MEDIA_TEXT_R_XML( TEXT_R_XML, of( "Rxml" ) ),
4948
  MEDIA_TEXT_XHTML( TEXT_XHTML, of( "xhtml" ) ),
5049
  MEDIA_TEXT_XML( TEXT_XML ),
M src/main/java/com/keenwrite/preferences/SimpleFontControl.java
126126
    final var frontPanel = (Region) pane.getContent();
127127
    for( final var node : frontPanel.getChildrenUnmodifiable() ) {
128
      if( node instanceof ListView ) {
129
        final var listView = (ListView<?>) node;
128
      if( node instanceof final ListView<?> listView ) {
130129
        final var handler = new ListViewHandler<>( listView );
131130
        listView.setOnKeyPressed( handler::handle );
M src/main/java/com/keenwrite/preview/ChainedReplacedElementFactory.java
22
package com.keenwrite.preview;
33
4
import com.keenwrite.preferences.Workspace;
54
import com.keenwrite.ui.adapters.ReplacedElementAdapter;
65
import com.keenwrite.util.BoundedCache;
...
1312
import org.xhtmlrenderer.swing.ImageReplacedElement;
1413
15
import java.awt.event.ComponentEvent;
16
import java.awt.event.ComponentListener;
1714
import java.util.LinkedHashSet;
1815
import java.util.Map;
1916
import java.util.Set;
2017
21
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_RESIZE;
2218
import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE;
2319
import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE_SRC;
2420
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
21
import static java.lang.Math.min;
2522
import static java.util.Arrays.asList;
2623
2724
/**
2825
 * Responsible for running one or more factories to perform post-processing on
2926
 * the HTML document prior to displaying it.
3027
 */
3128
public final class ChainedReplacedElementFactory
32
  extends ReplacedElementAdapter implements ComponentListener {
29
  extends ReplacedElementAdapter {
3330
  /**
3431
   * Retain insertion order so that client classes can control the order that
...
4340
   */
4441
  private final Map<String, ReplacedElement> mCache = new BoundedCache<>( 150 );
45
46
  private final Workspace mWorkspace;
4742
4843
  public ChainedReplacedElementFactory(
49
    final Workspace workspace, final ReplacedElementFactory... factories ) {
50
    assert workspace != null;
44
    final ReplacedElementFactory... factories ) {
5145
    assert factories != null;
5246
    assert factories.length > 0;
53
    mWorkspace = workspace;
5447
    mFactories.addAll( asList( factories ) );
5548
  }
...
8881
          final var r = f.createReplacedElement( c, box, uac, width, height );
8982
          return r instanceof final ImageReplacedElement ire
90
            ? new SmoothImageReplacedElement(
91
            ire.getImage(), box.getWidth(), -1 )
83
            ? createImageElement( box, ire )
9284
            : r;
9385
        }
...
124116
  }
125117
126
  @Override
127
  public void componentResized( final ComponentEvent e ) {
128
    if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
129
      clearCache();
130
    }
118
  /**
119
   * Creates a new image that maintains its aspect ratio while fitting into
120
   * the given {@link BlockBox}. If the image is too big, it is scaled down.
121
   *
122
   * @param box The bounding region the image must fit into.
123
   * @param ire The image to resize.
124
   * @return An image that is scaled down to fit, but only if necessary.
125
   */
126
  private SmoothImageReplacedElement createImageElement(
127
    final BlockBox box, final ImageReplacedElement ire ) {
128
    return new SmoothImageReplacedElement(
129
      ire.getImage(), min( ire.getIntrinsicWidth(), box.getWidth() ), -1 );
131130
  }
132
133
  @Override
134
  public void componentMoved( final ComponentEvent e ) { }
135
136
  @Override
137
  public void componentShown( final ComponentEvent e ) { }
138
139
  @Override
140
  public void componentHidden( final ComponentEvent e ) { }
141131
}
132
M src/main/java/com/keenwrite/preview/DomConverter.java
4343
    @Override
4444
    public void head( final Node node, final int depth ) {
45
      if( node instanceof TextNode ) {
45
      if( node instanceof final TextNode textNode ) {
4646
        final var parent = node.parentNode();
4747
        final var name = parent == null ? "root" : parent.nodeName();
4848
4949
        if( !("pre".equalsIgnoreCase( name ) ||
5050
          "code".equalsIgnoreCase( name ) ||
5151
          "tt".equalsIgnoreCase( name )) ) {
5252
          // Calling getWholeText() will return newlines, which must be kept
5353
          // to ensure that preformatted text maintains its formatting.
54
          final var textNode = (TextNode) node;
5554
          textNode.text( replace( textNode.getWholeText(), LIGATURES ) );
5655
        }
M src/main/java/com/keenwrite/preview/HtmlPreview.java
1414
import javax.swing.*;
1515
import java.awt.*;
16
import java.net.URL;
17
import java.nio.file.Path;
18
import java.util.Locale;
19
20
import static com.keenwrite.Messages.get;
21
import static com.keenwrite.constants.Constants.*;
22
import static com.keenwrite.events.Bus.register;
23
import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
24
import static com.keenwrite.events.StatusEvent.clue;
25
import static com.keenwrite.preferences.WorkspaceKeys.*;
26
import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
27
import static java.awt.BorderLayout.*;
28
import static java.awt.event.KeyEvent.*;
29
import static java.lang.Math.max;
30
import static java.lang.String.format;
31
import static java.lang.Thread.sleep;
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
39
/**
40
 * Responsible for parsing an HTML document.
41
 */
42
public final class HtmlPreview extends SwingNode {
43
44
  /**
45
   * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
46
   */
47
  private static final String HTML_STYLESHEET =
48
    "<link rel='stylesheet' href='%s'>";
49
50
  private static final String HTML_BASE =
51
    "<base href='%s'>";
52
53
  /**
54
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
55
   * poor rendering. The {@link #generateHead()} method fills placeholders.
56
   * When the user has not set a locale, only one stylesheet is added to
57
   * the document. In order, the placeholders are as follows:
58
   * <ol>
59
   * <li>%s --- language</li>
60
   * <li>%s --- default stylesheet</li>
61
   * <li>%s --- language-specific stylesheet</li>
62
   * <li>%s --- font family</li>
63
   * <li>%d --- font size (must be pixels, not points due to bug)</li>
64
   * <li>%s --- base href</li>
65
   * </p>
66
   */
67
  private static final String HTML_HEAD =
68
    """
69
      <!doctype html>
70
      <html lang='%s'><head><title> </title><meta charset='utf-8'>
71
      %s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
72
      """;
73
74
  private static final String HTML_TAIL = "</body></html>";
75
76
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
77
78
  /**
79
   * The order is important: Swing factory will replace SVG images with
80
   * a blank image, which will cause the chained factory to cache the image
81
   * and exit. Instead, the SVG must execute first to rasterize the content.
82
   * Consequently, the chained factory must maintain insertion order.
83
   */
84
  private final ChainedReplacedElementFactory mFactory;
85
86
  /**
87
   * Reusing this buffer prevents repetitious memory re-allocations.
88
   */
89
  private final StringBuilder mDocument = new StringBuilder( 65536 );
90
91
  private HtmlPanel mView;
92
  private JScrollPane mScrollPane;
93
  private String mBaseUriPath = "";
94
  private String mHead = "";
95
96
  private volatile boolean mLocked;
97
  private final JButton mScrollLockButton = new JButton();
98
  private final Workspace mWorkspace;
99
100
  /**
101
   * Creates a new preview pane that can scroll to the caret position within the
102
   * document.
103
   *
104
   * @param workspace Contains locale and font size information.
105
   */
106
  public HtmlPreview( final Workspace workspace ) {
107
    mWorkspace = workspace;
108
    mFactory = new ChainedReplacedElementFactory(
109
      mWorkspace,
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( mFactory );
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 url = toUrl( locale );
214
    final var base = getBaseUri();
215
216
    // Point sizes are converted to pixels because of a rendering bug.
217
    return format(
218
      HTML_HEAD,
219
      locale.getLanguage(),
220
      format( HTML_STYLESHEET, HTML_STYLE_PREVIEW ),
221
      url == null ? "" : format( HTML_STYLESHEET, url ),
222
      getFontFamily(),
223
      toPixels( getFontSize() ),
224
      base.isBlank() ? "" : format( HTML_BASE, base )
225
    );
226
  }
227
228
  /**
229
   * Clears the preview pane by rendering an empty string.
230
   */
231
  public void clear() {
232
    render( "" );
233
  }
234
235
  /**
236
   * Sets the base URI to the containing directory the file being edited.
237
   *
238
   * @param path The path to the file being edited.
239
   */
240
  public void setBaseUri( final Path path ) {
241
    final var parent = path.getParent();
242
    mBaseUriPath = parent == null ? "" : parent.toUri().toString();
243
  }
244
245
  /**
246
   * Scrolls to the closest element matching the given identifier without
247
   * waiting for the document to be ready.
248
   *
249
   * @param id Scroll the preview pane to this unique paragraph identifier.
250
   */
251
  public void scrollTo( final String id ) {
252
    if( mLocked ) {
253
      return;
254
    }
255
256
    invokeLater( () -> {
257
      int iter = 0;
258
      Box box = null;
259
260
      while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) {
261
        try {
262
          sleep( 10 );
263
        } catch( final Exception ex ) {
264
          clue( ex );
265
        }
266
      }
267
268
      scrollTo( box );
269
    } );
270
  }
271
272
  /**
273
   * Scrolls to the location specified by the {@link Box} that corresponds
274
   * to a point somewhere in the preview pane. If there is no caret, then
275
   * this will not change the scroll position. Changing the scroll position
276
   * to the top if the {@link Box} instance is {@code null} will result in
277
   * jumping around a lot and inconsistent synchronization issues.
278
   *
279
   * @param box The rectangular region containing the caret, or {@code null}
280
   *            if the HTML does not have a caret.
281
   */
282
  private void scrollTo( final Box box ) {
283
    if( box != null ) {
284
      invokeLater( () -> {
285
        mView.scrollTo( createPoint( box ) );
286
        getScrollPane().repaint();
287
      } );
288
    }
289
  }
290
291
  /**
292
   * Creates a {@link Point} to use as a reference for scrolling to the area
293
   * described by the given {@link Box}. The {@link Box} coordinates are used
294
   * to populate the {@link Point}'s location, with minor adjustments for
295
   * vertical centering.
296
   *
297
   * @param box The {@link Box} that represents a scrolling anchor reference.
298
   * @return A coordinate suitable for scrolling to.
299
   */
300
  private Point createPoint( final Box box ) {
301
    assert box != null;
302
303
    // Scroll back up by half the height of the scroll bar to keep the typing
304
    // area within the view port. Otherwise the view port will have jumped too
305
    // high up and the most recently typed letters won't be visible.
306
    int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 );
307
    int x = box.getAbsX();
308
309
    if( !box.getStyle().isInline() ) {
310
      final var margin = box.getMargin( mView.getLayoutContext() );
311
      y += margin.top();
312
      x += margin.left();
313
    }
314
315
    return new Point( x, y );
316
  }
317
318
  private String getBaseUri() {
319
    return mBaseUriPath;
320
  }
321
322
  private JScrollPane getScrollPane() {
323
    return mScrollPane;
324
  }
325
326
  public JScrollBar getVerticalScrollBar() {
327
    return getScrollPane().getVerticalScrollBar();
328
  }
329
330
  private int getVerticalScrollBarHeight() {
331
    return getVerticalScrollBar().getHeight();
332
  }
333
334
  /**
335
   * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
336
   * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
337
   * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
338
   * could return "en-Latn-CA" for Canadian English written in the Latin
339
   * character set.
340
   *
341
   * @return Unique identifier for language and country.
342
   */
343
  private static URL toUrl( final Locale locale ) {
344
    return toUrl(
345
      get(
346
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
347
        locale.getLanguage(),
348
        locale.getScript(),
349
        locale.getCountry()
350
      )
351
    );
352
  }
353
354
  private static URL toUrl( final String path ) {
355
    return HtmlPreview.class.getResource( path );
356
  }
357
358
  private Locale getLocale() {
359
    return localeProperty().toLocale();
360
  }
361
362
  private LocaleProperty localeProperty() {
363
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
364
  }
365
366
  private String getFontFamily() {
367
    return fontFamilyProperty().get();
368
  }
369
370
  private StringProperty fontFamilyProperty() {
371
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
372
  }
373
374
  private double getFontSize() {
375
    return fontSizeProperty().get();
376
  }
377
378
  /**
379
   * Returns the font size in points.
380
   *
381
   * @return The user-defined font size (in pt).
382
   */
383
  private DoubleProperty fontSizeProperty() {
384
    return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
385
  }
386
387
  private String getLockText( final boolean locked ) {
388
    return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
389
  }
390
391
  /**
392
   * Maps keyboard events to scrollbar commands so that users may control
393
   * the {@link HtmlPreview} panel using the keyboard.
394
   *
395
   * @param map The map to update with keyboard events.
396
   */
397
  private void addKeyboardEvents( final InputMap map ) {
398
    map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
399
    map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
400
    map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
401
    map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
402
    map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
403
    map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
404
  }
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 --- font family</li>
64
   * <li>%d --- font size (must be pixels, not points due to bug)</li>
65
   * <li>%s --- base href</li>
66
   * </p>
67
   */
68
  private static final String HTML_HEAD =
69
    """
70
      <!doctype html>
71
      <html lang='%s'><head><title> </title><meta charset='utf-8'>
72
      %s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
73
      """;
74
75
  private static final String HTML_TAIL = "</body></html>";
76
77
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
78
79
  private final ChainedReplacedElementFactory mFactory;
80
81
  /**
82
   * Reusing this buffer prevents repetitious memory re-allocations.
83
   */
84
  private final StringBuilder mDocument = new StringBuilder( 65536 );
85
86
  private HtmlPanel mView;
87
  private JScrollPane mScrollPane;
88
  private String mBaseUriPath = "";
89
  private String mHead = "";
90
91
  private volatile boolean mLocked;
92
  private final JButton mScrollLockButton = new JButton();
93
  private final Workspace mWorkspace;
94
95
  /**
96
   * Creates a new preview pane that can scroll to the caret position within the
97
   * document.
98
   *
99
   * @param workspace Contains locale and font size information.
100
   */
101
  public HtmlPreview( final Workspace workspace ) {
102
    mWorkspace = workspace;
103
104
    // The order is important: SwingReplacedElementFactory replaces SVG images
105
    // with a blank image, which will cause the chained factory to cache the
106
    // image and exit. Instead, the SVG must execute first to rasterize the
107
    // content. Consequently, the chained factory must maintain insertion order.
108
    mFactory = new ChainedReplacedElementFactory(
109
      new SvgReplacedElementFactory(),
110
      new SwingReplacedElementFactory()
111
    );
112
113
    // Attempts to prevent a flash of black un-styled content upon load.
114
    setStyle( "-fx-background-color: white;" );
115
116
    invokeLater( () -> {
117
      mHead = generateHead();
118
      mView = new HtmlPanel();
119
      mScrollPane = new JScrollPane( mView );
120
      final var verticalBar = mScrollPane.getVerticalScrollBar();
121
      final var verticalPanel = new JPanel( new BorderLayout() );
122
123
      final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW );
124
      addKeyboardEvents( map );
125
126
      mScrollLockButton.setFont( getIconFont( 14 ) );
127
      mScrollLockButton.setText( getLockText( mLocked ) );
128
      mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
129
      mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
130
131
      verticalPanel.add( verticalBar, CENTER );
132
      verticalPanel.add( mScrollLockButton, PAGE_END );
133
134
      final var wrapper = new JPanel( new BorderLayout() );
135
      wrapper.add( mScrollPane, CENTER );
136
      wrapper.add( verticalPanel, LINE_END );
137
138
      // Enabling the cache attempts to prevent black flashes when resizing.
139
      setCache( true );
140
      setCacheHint( SPEED );
141
      setContent( wrapper );
142
      wrapper.addComponentListener( this );
143
144
      final var context = mView.getSharedContext();
145
      final var textRenderer = context.getTextRenderer();
146
      context.setReplacedElementFactory( mFactory );
147
      textRenderer.setSmoothingThreshold( 0 );
148
149
      localeProperty().addListener( ( c, o, n ) -> rerender() );
150
      fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
151
      fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
152
    } );
153
154
    register( this );
155
  }
156
157
  @Subscribe
158
  public void handle( final ScrollLockEvent event ) {
159
    mLocked = event.isLocked();
160
    invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
161
  }
162
163
  /**
164
   * Updates the internal HTML source shown in the preview pane.
165
   *
166
   * @param html The new HTML document to display.
167
   */
168
  public void render( final String html ) {
169
    mView.render( decorate( html ), getBaseUri() );
170
  }
171
172
  /**
173
   * Clears the caches then re-renders the content.
174
   */
175
  public void refresh() {
176
    mFactory.clearCache();
177
    rerender();
178
  }
179
180
  /**
181
   * Recomputes the HTML head then renders the document.
182
   */
183
  private void rerender() {
184
    mHead = generateHead();
185
    render( mDocument.toString() );
186
  }
187
188
  /**
189
   * Attaches the HTML head prefix and HTML tail suffix to the given HTML
190
   * string.
191
   *
192
   * @param html The HTML to adorn with opening and closing tags.
193
   * @return A complete HTML document, ready for rendering.
194
   */
195
  private String decorate( final String html ) {
196
    mDocument.setLength( 0 );
197
    mDocument.append( html );
198
199
    // Head and tail must be separate from document due to re-rendering.
200
    return mHead + mDocument + HTML_TAIL;
201
  }
202
203
  /**
204
   * Called when settings are changed that affect the HTML document preamble.
205
   * This is a minor performance optimization to avoid generating the head
206
   * each time that the document itself changes.
207
   *
208
   * @return A new doctype and HTML {@code head} element.
209
   */
210
  private String generateHead() {
211
    final var locale = getLocale();
212
    final var url = toUrl( locale );
213
    final var base = getBaseUri();
214
215
    // Point sizes are converted to pixels because of a rendering bug.
216
    return format(
217
      HTML_HEAD,
218
      locale.getLanguage(),
219
      format( HTML_STYLESHEET, HTML_STYLE_PREVIEW ),
220
      url == null ? "" : format( HTML_STYLESHEET, url ),
221
      getFontFamily(),
222
      toPixels( getFontSize() ),
223
      base.isBlank() ? "" : format( HTML_BASE, base )
224
    );
225
  }
226
227
  /**
228
   * Clears the preview pane by rendering an empty string.
229
   */
230
  public void clear() {
231
    render( "" );
232
  }
233
234
  /**
235
   * Sets the base URI to the containing directory the file being edited.
236
   *
237
   * @param path The path to the file being edited.
238
   */
239
  public void setBaseUri( final Path path ) {
240
    final var parent = path.getParent();
241
    mBaseUriPath = parent == null ? "" : parent.toUri().toString();
242
  }
243
244
  /**
245
   * Scrolls to the closest element matching the given identifier without
246
   * waiting for the document to be ready.
247
   *
248
   * @param id Scroll the preview pane to this unique paragraph identifier.
249
   */
250
  public void scrollTo( final String id ) {
251
    if( mLocked ) {
252
      return;
253
    }
254
255
    invokeLater( () -> {
256
      int iter = 0;
257
      Box box = null;
258
259
      while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) {
260
        try {
261
          sleep( 10 );
262
        } catch( final Exception ex ) {
263
          clue( ex );
264
        }
265
      }
266
267
      scrollTo( box );
268
    } );
269
  }
270
271
  /**
272
   * Scrolls to the location specified by the {@link Box} that corresponds
273
   * to a point somewhere in the preview pane. If there is no caret, then
274
   * this will not change the scroll position. Changing the scroll position
275
   * to the top if the {@link Box} instance is {@code null} will result in
276
   * jumping around a lot and inconsistent synchronization issues.
277
   *
278
   * @param box The rectangular region containing the caret, or {@code null}
279
   *            if the HTML does not have a caret.
280
   */
281
  private void scrollTo( final Box box ) {
282
    if( box != null ) {
283
      invokeLater( () -> {
284
        mView.scrollTo( createPoint( box ) );
285
        getScrollPane().repaint();
286
      } );
287
    }
288
  }
289
290
  /**
291
   * Creates a {@link Point} to use as a reference for scrolling to the area
292
   * described by the given {@link Box}. The {@link Box} coordinates are used
293
   * to populate the {@link Point}'s location, with minor adjustments for
294
   * vertical centering.
295
   *
296
   * @param box The {@link Box} that represents a scrolling anchor reference.
297
   * @return A coordinate suitable for scrolling to.
298
   */
299
  private Point createPoint( final Box box ) {
300
    assert box != null;
301
302
    // Scroll back up by half the height of the scroll bar to keep the typing
303
    // area within the view port. Otherwise the view port will have jumped too
304
    // high up and the most recently typed letters won't be visible.
305
    int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 );
306
    int x = box.getAbsX();
307
308
    if( !box.getStyle().isInline() ) {
309
      final var margin = box.getMargin( mView.getLayoutContext() );
310
      y += margin.top();
311
      x += margin.left();
312
    }
313
314
    return new Point( x, y );
315
  }
316
317
  private String getBaseUri() {
318
    return mBaseUriPath;
319
  }
320
321
  private JScrollPane getScrollPane() {
322
    return mScrollPane;
323
  }
324
325
  public JScrollBar getVerticalScrollBar() {
326
    return getScrollPane().getVerticalScrollBar();
327
  }
328
329
  private int getVerticalScrollBarHeight() {
330
    return getVerticalScrollBar().getHeight();
331
  }
332
333
  /**
334
   * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
335
   * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
336
   * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
337
   * could return "en-Latn-CA" for Canadian English written in the Latin
338
   * character set.
339
   *
340
   * @return Unique identifier for language and country.
341
   */
342
  private static URL toUrl( final Locale locale ) {
343
    return toUrl(
344
      get(
345
        sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
346
        locale.getLanguage(),
347
        locale.getScript(),
348
        locale.getCountry()
349
      )
350
    );
351
  }
352
353
  private static URL toUrl( final String path ) {
354
    return HtmlPreview.class.getResource( path );
355
  }
356
357
  private Locale getLocale() {
358
    return localeProperty().toLocale();
359
  }
360
361
  private LocaleProperty localeProperty() {
362
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
363
  }
364
365
  private String getFontFamily() {
366
    return fontFamilyProperty().get();
367
  }
368
369
  private StringProperty fontFamilyProperty() {
370
    return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
371
  }
372
373
  private double getFontSize() {
374
    return fontSizeProperty().get();
375
  }
376
377
  /**
378
   * Returns the font size in points.
379
   *
380
   * @return The user-defined font size (in pt).
381
   */
382
  private DoubleProperty fontSizeProperty() {
383
    return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
384
  }
385
386
  private String getLockText( final boolean locked ) {
387
    return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
388
  }
389
390
  /**
391
   * Maps keyboard events to scrollbar commands so that users may control
392
   * the {@link HtmlPreview} panel using the keyboard.
393
   *
394
   * @param map The map to update with keyboard events.
395
   */
396
  private void addKeyboardEvents( final InputMap map ) {
397
    map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" );
398
    map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" );
399
    map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" );
400
    map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" );
401
    map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" );
402
    map.put( getKeyStroke( VK_END, 0 ), "maxScroll" );
403
  }
404
405
  @Override
406
  public void componentResized( final ComponentEvent e ) {
407
    if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
408
      mFactory.clearCache();
409
    }
410
411
    // Force update on the Swing EDT, otherwise the scrollbar and content
412
    // will not be updated correctly on some platforms.
413
    invokeLater( () -> getContent().repaint() );
414
  }
415
416
  @Override
417
  public void componentMoved( final ComponentEvent e ) { }
418
419
  @Override
420
  public void componentShown( final ComponentEvent e ) { }
421
422
  @Override
423
  public void componentHidden( final ComponentEvent e ) { }
405424
}
406425
M src/main/java/com/keenwrite/preview/SmoothImageReplacedElement.java
3838
  }
3939
40
  /**
41
   * Calculates scaled dimensions while maintaining the image aspect ratio.
42
   */
4043
  private Dimension rescaleDimensions(
4144
    final BufferedImage bi, final int width, final int height ) {
M src/main/java/com/keenwrite/processors/DefinitionProcessor.java
33
44
import java.util.Map;
5
import java.util.function.Function;
56
67
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
78
89
/**
910
 * Processes interpolated string definitions in the document and inserts
1011
 * their values into the post-processed text. The default variable syntax is
1112
 * {@code $variable$}.
1213
 */
13
public class DefinitionProcessor extends ExecutorProcessor<String> {
14
public class DefinitionProcessor
15
  extends ExecutorProcessor<String> implements Function<String, String> {
1416
1517
  private final Map<String, String> mDefinitions;
M src/main/java/com/keenwrite/processors/PdfProcessor.java
4343
4444
      // Smote the temporary file after typesetting the document.
45
      deleteIfExists( document );
45
      if( typesetter.autoclean() ) {
46
        deleteIfExists( document );
47
      }
48
      else {
49
        document.toFile().deleteOnExit();
50
      }
4651
    } catch( final IOException | InterruptedException ex ) {
4752
      // Typesetter runtime exceptions will pass up the call stack.
M src/main/java/com/keenwrite/processors/ProcessorContext.java
33
44
import com.keenwrite.Caret;
5
import com.keenwrite.constants.Constants;
65
import com.keenwrite.ExportFormat;
6
import com.keenwrite.constants.Constants;
77
import com.keenwrite.io.FileType;
88
import com.keenwrite.preferences.Workspace;
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
5050
    final var processor = switch( context.getFileType() ) {
5151
      case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor );
52
      case RXML -> createRXmlProcessor( successor );
53
      case XML -> createXmlProcessor( successor );
5452
      default -> createPreformattedProcessor( successor );
5553
    };
...
106104
    final Processor<String> successor ) {
107105
    return new DefinitionProcessor( successor, getProcessorContext() );
108
  }
109
110
  protected Processor<String> createRXmlProcessor(
111
    final Processor<String> successor ) {
112
    final var context = getProcessorContext();
113
    final var rp = MarkdownProcessor.create( successor, context );
114
    return new XmlProcessor( rp, context );
115
  }
116
117
  private Processor<String> createXmlProcessor(
118
    final Processor<String> successor ) {
119
    final var xmlp = new XmlProcessor( successor, getProcessorContext() );
120
    return createDefinitionProcessor( xmlp );
121106
  }
122107
D src/main/java/com/keenwrite/processors/XmlProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors;
3
4
import net.sf.saxon.Configuration;
5
import net.sf.saxon.TransformerFactoryImpl;
6
import net.sf.saxon.om.IgnorableSpaceStrippingRule;
7
import net.sf.saxon.trans.XPathException;
8
9
import javax.xml.stream.XMLEventReader;
10
import javax.xml.stream.XMLInputFactory;
11
import javax.xml.stream.XMLStreamException;
12
import javax.xml.stream.events.ProcessingInstruction;
13
import javax.xml.transform.*;
14
import javax.xml.transform.stream.StreamResult;
15
import javax.xml.transform.stream.StreamSource;
16
import java.io.Reader;
17
import java.io.StringReader;
18
import java.io.StringWriter;
19
import java.nio.file.Path;
20
import java.nio.file.Paths;
21
22
import static com.keenwrite.events.StatusEvent.clue;
23
import static javax.xml.stream.XMLInputFactory.newInstance;
24
import static net.sf.saxon.tree.util.ProcInstParser.getPseudoAttribute;
25
26
/**
27
 * Transforms an XML document. The XML document must have a stylesheet specified
28
 * as part of its processing instructions, such as:
29
 * <p>
30
 * {@code xml-stylesheet type="text/xsl" href="markdown.xsl"}
31
 * </p>
32
 * <p>
33
 * The XSL must transform the XML document into Markdown, or another format
34
 * recognized by the next link on the chain.
35
 * </p>
36
 */
37
public final class XmlProcessor extends ExecutorProcessor<String>
38
  implements ErrorListener {
39
40
  private final XMLInputFactory mXmlInputFactory = newInstance();
41
  private final Configuration mConfiguration = new Configuration();
42
  private final TransformerFactory mTransformerFactory =
43
    new TransformerFactoryImpl(mConfiguration);
44
  private Transformer mTransformer;
45
46
  private final Path mPath;
47
48
  /**
49
   * Constructs an XML processor that can transform an XML document into another
50
   * format based on the XSL file specified as a processing instruction. The
51
   * path must point to the directory where the XSL file is found, which implies
52
   * that they must be in the same directory.
53
   *
54
   * @param successor Next link in the processing chain.
55
   * @param context   Contains path to the XML file content to be processed.
56
   */
57
  public XmlProcessor(
58
    final Processor<String> successor,
59
    final ProcessorContext context ) {
60
    super( successor );
61
    mPath = context.getDocumentPath();
62
63
    // Bubble problems up to the user interface, rather than standard error.
64
    mTransformerFactory.setErrorListener( this );
65
    final var options = mConfiguration.getParseOptions();
66
    options.setSpaceStrippingRule( IgnorableSpaceStrippingRule.getInstance() );
67
  }
68
69
  /**
70
   * Transforms the given XML text into another form (typically Markdown).
71
   *
72
   * @param text The text to transform, can be empty, cannot be null.
73
   * @return The transformed text, or empty if text is empty.
74
   */
75
  @Override
76
  public String apply( final String text ) {
77
    try {
78
      return text.isEmpty() ? text : transform( text );
79
    } catch( final Exception ex ) {
80
      clue( ex );
81
      throw new RuntimeException( ex );
82
    }
83
  }
84
85
  /**
86
   * Performs an XSL transformation on the given XML text. The XML text must
87
   * have a processing instruction that points to the XSL template file to use
88
   * for the transformation.
89
   *
90
   * @param text The text to transform.
91
   * @return The transformed text.
92
   */
93
  private String transform( final String text ) throws Exception {
94
    try(
95
      final var output = new StringWriter( text.length() );
96
      final var input = new StringReader( text ) ) {
97
      // Extract the XML stylesheet processing instruction.
98
      final var template = getXsltFilename( text );
99
      final var xsl = getXslPath( template );
100
101
      // TODO: Use FileWatchService
102
      // Listen for external file modification events.
103
      // mSnitch.listen( xsl );
104
105
      getTransformer( xsl ).transform(
106
        new StreamSource( input ),
107
        new StreamResult( output )
108
      );
109
110
      return output.toString();
111
    }
112
  }
113
114
  /**
115
   * Returns an XSL transformer ready to transform an XML document using the
116
   * XSLT file specified by the given path. If the path is already known then
117
   * this will return the associated transformer.
118
   *
119
   * @param xsl The path to an XSLT file.
120
   * @return Transformer that transforms XML documents using said XSLT file.
121
   * @throws TransformerConfigurationException Instantiate transformer failed.
122
   */
123
  private synchronized Transformer getTransformer( final Path xsl )
124
    throws TransformerConfigurationException {
125
    if( mTransformer == null ) {
126
      mTransformer = createTransformer( xsl );
127
    }
128
129
    return mTransformer;
130
  }
131
132
  /**
133
   * Creates a configured transformer ready to run.
134
   *
135
   * @param xsl The stylesheet to use for transforming XML documents.
136
   * @return XML document transformed into another format (usually Markdown).
137
   * @throws TransformerConfigurationException Could not create the transformer.
138
   */
139
  protected Transformer createTransformer( final Path xsl )
140
    throws TransformerConfigurationException {
141
    final var xslt = new StreamSource( xsl.toFile() );
142
143
    return getTransformerFactory().newTransformer( xslt );
144
  }
145
146
  private Path getXslPath( final String filename ) {
147
    final var xmlDirectory = mPath.toFile().getParentFile();
148
149
    return Paths.get( xmlDirectory.getPath(), filename );
150
  }
151
152
  /**
153
   * Given XML text, this will use a StAX pull reader to obtain the XML
154
   * stylesheet processing instruction. This will throw a parse exception if the
155
   * href pseudo-attribute file name value cannot be found.
156
   *
157
   * @param xml The XML containing an xml-stylesheet processing instruction.
158
   * @return The href pseudo-attribute value.
159
   * @throws XMLStreamException Could not parse the XML file.
160
   */
161
  private String getXsltFilename( final String xml )
162
    throws XMLStreamException, XPathException {
163
    var result = "";
164
165
    try( final var sr = new StringReader( xml ) ) {
166
      final var reader = createXmlEventReader( sr );
167
      var found = false;
168
      var count = 0;
169
170
      // If the processing instruction wasn't found in the first 10 lines,
171
      // fail fast. This should iterate twice through the loop.
172
      while( !found && reader.hasNext() && count++ < 10 ) {
173
        final var event = reader.nextEvent();
174
175
        if( event.isProcessingInstruction() ) {
176
          final var pi = (ProcessingInstruction) event;
177
          final var target = pi.getTarget();
178
179
          if( "xml-stylesheet".equalsIgnoreCase( target ) ) {
180
            result = getPseudoAttribute( pi.getData(), "href" );
181
            found = true;
182
          }
183
        }
184
      }
185
    }
186
187
    return result;
188
  }
189
190
  private XMLEventReader createXmlEventReader( final Reader reader )
191
    throws XMLStreamException {
192
    return mXmlInputFactory.createXMLEventReader( reader );
193
  }
194
195
  private synchronized TransformerFactory getTransformerFactory() {
196
    return mTransformerFactory;
197
  }
198
199
  /**
200
   * Called when the XSL transformer issues a warning.
201
   *
202
   * @param ex The problem the transformer encountered.
203
   */
204
  @Override
205
  public void warning( final TransformerException ex ) {
206
    clue( ex );
207
  }
208
209
  /**
210
   * Called when the XSL transformer issues an error.
211
   *
212
   * @param ex The problem the transformer encountered.
213
   */
214
  @Override
215
  public void error( final TransformerException ex ) {
216
    clue( ex );
217
  }
218
219
  /**
220
   * Called when the XSL transformer issues a fatal error, which is probably
221
   * a bit over-dramatic for a method name.
222
   *
223
   * @param ex The problem the transformer encountered.
224
   */
225
  @Override
226
  public void fatalError( final TransformerException ex ) {
227
    clue( ex );
228
  }
229
}
2301
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
1717
1818
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
19
import static com.keenwrite.io.MediaType.TEXT_R_XML;
2019
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
2120
...
5958
    final List<Extension> extensions = new ArrayList<>();
6059
61
    if( mediaType == TEXT_R_MARKDOWN || mediaType == TEXT_R_XML ) {
60
    if( mediaType == TEXT_R_MARKDOWN ) {
6261
      final var rProcessor = new RProcessor( context );
6362
      extensions.add( RExtension.create( rProcessor, context ) );
M src/main/java/com/keenwrite/processors/markdown/extensions/CaretExtension.java
3737
3838
  @Override
39
  public void extend(
40
    final Builder builder, @NotNull final String rendererType ) {
39
  public void extend( @NotNull final Builder builder,
40
                      @NotNull final String rendererType ) {
4141
    builder.attributeProviderFactory(
4242
      IdAttributeProvider.createFactory( mCaret ) );
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
5454
5555
  @Override
56
  public void extend(
57
    @NotNull final Builder builder, @NotNull final String rendererType ) {
56
  public void extend( @NotNull final Builder builder,
57
                      @NotNull final String rendererType ) {
5858
    builder.linkResolverFactory( new ResolverFactory() );
5959
  }
M src/main/java/com/keenwrite/processors/r/RProcessor.java
66
import com.keenwrite.processors.ProcessorContext;
77
8
import java.util.function.Function;
9
810
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
911
1012
/**
1113
 * Responsible for processing R statements within a text block.
1214
 */
13
public final class RProcessor extends ExecutorProcessor<String> {
15
public final class RProcessor
16
  extends ExecutorProcessor<String> implements Function<String, String> {
1417
  private final Processor<String> mProcessor;
1518
  private final InlineRProcessor mInlineRProcessor;
M src/main/java/com/keenwrite/spelling/impl/TextEditorSpeller.java
2727
  private static final SpellChecker sSpellChecker = forLexicon( "en.txt" );
2828
29
  private final Parser mParser;
30
2931
  public TextEditorSpeller() {
32
     mParser = Parser.builder().build();
3033
  }
3134
...
118121
    }
119122
  }
120
121
  /**
122
   * TODO: #59 -- Replace using Markdown processor instantiated for Markdown
123
   * files.
124
   */
125
  private final Parser mParser = Parser.builder().build();
126123
127124
  /**
M src/main/java/com/keenwrite/typesetting/Typesetter.java
391391
   * @return {@code true} to delete generated files.
392392
   */
393
  private boolean autoclean() {
393
  public boolean autoclean() {
394394
    return mWorkspace.toBoolean( KEY_TYPESET_CONTEXT_CLEAN );
395395
  }
M src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
6464
 * using their respective syntax.
6565
 */
66
@SuppressWarnings( "NonAsciiCharacters" )
67
public final class ApplicationActions {
68
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
69
70
  private static final String STYLE_SEARCH = "search";
71
72
  /**
73
   * Sci-fi genres, which are can be longer than other genres, typically fall
74
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
75
   * memory when concatenating files together when exporting novels.
76
   */
77
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
78
79
  /**
80
   * When an action is executed, this is one of the recipients.
81
   */
82
  private final MainPane mMainPane;
83
84
  private final MainScene mMainScene;
85
86
  private final LogView mLogView;
87
88
  /**
89
   * Tracks finding text in the active document.
90
   */
91
  private final SearchModel mSearchModel;
92
93
  public ApplicationActions( final MainScene scene, final MainPane pane ) {
94
    mMainScene = scene;
95
    mMainPane = pane;
96
    mLogView = new LogView();
97
    mSearchModel = new SearchModel();
98
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
99
      final var editor = getActiveTextEditor();
100
101
      // Clear highlighted areas before highlighting a new region.
102
      if( o != null ) {
103
        editor.unstylize( STYLE_SEARCH );
104
      }
105
106
      if( n != null ) {
107
        editor.moveTo( n.getStart() );
108
        editor.stylize( n, STYLE_SEARCH );
109
      }
110
    } );
111
112
    // When the active text editor changes, update the haystack.
113
    mMainPane.activeTextEditorProperty().addListener(
114
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
115
    );
116
  }
117
118
  public void file‿new() {
119
    getMainPane().newTextEditor();
120
  }
121
122
  public void file‿open() {
123
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
124
  }
125
126
  public void file‿close() {
127
    getMainPane().close();
128
  }
129
130
  public void file‿close_all() {
131
    getMainPane().closeAll();
132
  }
133
134
  public void file‿save() {
135
    getMainPane().save();
136
  }
137
138
  public void file‿save_as() {
139
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
140
  }
141
142
  public void file‿save_all() {
143
    getMainPane().saveAll();
144
  }
145
146
  /**
147
   * Converts the actively edited file in the given file format.
148
   *
149
   * @param format The destination file format.
150
   */
151
  private void file‿export( final ExportFormat format ) {
152
    file‿export( format, false );
153
  }
154
155
  /**
156
   * Converts one or more files into the given file format. If {@code dir}
157
   * is set to true, this will first append all files in the same directory
158
   * as the actively edited file.
159
   *
160
   * @param format The destination file format.
161
   * @param dir    Export all files in the actively edited file's directory.
162
   */
163
  private void file‿export( final ExportFormat format, final boolean dir ) {
164
    final var main = getMainPane();
165
    final var editor = main.getActiveTextEditor();
166
    final var filename = format.toExportFilename( editor.getPath() );
167
    final var selection = pickFiles( filename, FILE_EXPORT );
168
169
    selection.ifPresent( ( files ) -> {
170
      final var file = files.get( 0 );
171
      final var path = file.toPath();
172
      final var document = dir ? append( editor ) : editor.getText();
173
      final var context = main.createProcessorContext( path, format );
174
175
      final var task = new Task<Path>() {
176
        @Override
177
        protected Path call() throws Exception {
178
          final var chain = createProcessors( context );
179
          final var export = chain.apply( document );
180
181
          // Processors can export binary files. In such cases, processors
182
          // return null to prevent further processing.
183
          return export == null ? null : writeString( path, export );
184
        }
185
      };
186
187
      task.setOnSucceeded(
188
        e -> {
189
          final var result = task.getValue();
190
191
          // Binary formats must notify users of success independently.
192
          if( result != null ) {
193
            clue( "Main.status.export.success", result );
194
          }
195
        }
196
      );
197
198
      task.setOnFailed( e -> {
199
        final var ex = task.getException();
200
        clue( ex );
201
202
        if( ex instanceof TypeNotPresentException ) {
203
          fireExportFailedEvent();
204
        }
205
      } );
206
207
      sExecutor.execute( task );
208
    } );
209
  }
210
211
  /**
212
   * @param dir {@code true} means to export all files in the active file
213
   *            editor's directory; {@code false} means to export only the
214
   *            actively edited file.
215
   */
216
  private void file‿export‿pdf( final boolean dir ) {
217
    final var workspace = getWorkspace();
218
    final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
219
    final var theme = workspace.stringProperty(
220
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
221
222
    if( Typesetter.canRun() ) {
223
      // If the typesetter is installed, allow the user to select a theme. If
224
      // the themes aren't installed, a status message will appear.
225
      if( ThemePicker.choose( themes, theme ) ) {
226
        file‿export( APPLICATION_PDF, dir );
227
      }
228
    }
229
    else {
230
      fireExportFailedEvent();
231
    }
232
  }
233
234
  public void file‿export‿pdf() {
235
    file‿export‿pdf( false );
236
  }
237
238
  public void file‿export‿pdf‿dir() {
239
    file‿export‿pdf( true );
240
  }
241
242
  public void file‿export‿html_svg() {
243
    file‿export( HTML_TEX_SVG );
244
  }
245
246
  public void file‿export‿html_tex() {
247
    file‿export( HTML_TEX_DELIMITED );
248
  }
249
250
  public void file‿export‿xhtml_tex() {
251
    file‿export( XHTML_TEX );
252
  }
253
254
  public void file‿export‿markdown() {
255
    file‿export( MARKDOWN_PLAIN );
256
  }
257
258
  private void fireExportFailedEvent() {
259
    runLater( ExportFailedEvent::fireExportFailedEvent );
260
  }
261
262
  public void file‿exit() {
263
    final var window = getWindow();
264
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
265
  }
266
267
  public void edit‿undo() {
268
    getActiveTextEditor().undo();
269
  }
270
271
  public void edit‿redo() {
272
    getActiveTextEditor().redo();
273
  }
274
275
  public void edit‿cut() {
276
    getActiveTextEditor().cut();
277
  }
278
279
  public void edit‿copy() {
280
    getActiveTextEditor().copy();
281
  }
282
283
  public void edit‿paste() {
284
    getActiveTextEditor().paste();
285
  }
286
287
  public void edit‿select_all() {
288
    getActiveTextEditor().selectAll();
289
  }
290
291
  public void edit‿find() {
292
    final var nodes = getMainScene().getStatusBar().getLeftItems();
293
294
    if( nodes.isEmpty() ) {
295
      final var searchBar = new SearchBar();
296
297
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
298
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
299
300
      searchBar.setOnCancelAction( ( event ) -> {
301
        final var editor = getActiveTextEditor();
302
        nodes.remove( searchBar );
303
        editor.unstylize( STYLE_SEARCH );
304
        editor.getNode().requestFocus();
305
      } );
306
307
      searchBar.addInputListener( ( c, o, n ) -> {
308
        if( n != null && !n.isEmpty() ) {
309
          mSearchModel.search( n, getActiveTextEditor().getText() );
310
        }
311
      } );
312
313
      searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
314
      searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
315
316
      nodes.add( searchBar );
317
      searchBar.requestFocus();
318
    }
319
    else {
320
      nodes.clear();
321
    }
322
  }
323
324
  public void edit‿find_next() {
325
    mSearchModel.advance();
326
  }
327
328
  public void edit‿find_prev() {
329
    mSearchModel.retreat();
330
  }
331
332
  public void edit‿preferences() {
333
    try {
334
      new PreferencesController( getWorkspace() ).show();
335
    } catch( final Exception ex ) {
336
      clue( ex );
337
    }
338
  }
339
340
  public void format‿bold() {
341
    getActiveTextEditor().bold();
342
  }
343
344
  public void format‿italic() {
345
    getActiveTextEditor().italic();
346
  }
347
348
  public void format‿monospace() {
349
    getActiveTextEditor().monospace();
350
  }
351
352
  public void format‿superscript() {
353
    getActiveTextEditor().superscript();
354
  }
355
356
  public void format‿subscript() {
357
    getActiveTextEditor().subscript();
358
  }
359
360
  public void format‿strikethrough() {
361
    getActiveTextEditor().strikethrough();
362
  }
363
364
  public void insert‿blockquote() {
365
    getActiveTextEditor().blockquote();
366
  }
367
368
  public void insert‿code() {
369
    getActiveTextEditor().code();
370
  }
371
372
  public void insert‿fenced_code_block() {
373
    getActiveTextEditor().fencedCodeBlock();
374
  }
375
376
  public void insert‿link() {
377
    insertObject( createLinkDialog() );
378
  }
379
380
  public void insert‿image() {
381
    insertObject( createImageDialog() );
382
  }
383
384
  private void insertObject( final Dialog<String> dialog ) {
385
    final var textArea = getActiveTextEditor().getTextArea();
386
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
387
  }
388
389
  private Dialog<String> createLinkDialog() {
390
    return new LinkDialog( getWindow(), createHyperlinkModel() );
391
  }
392
393
  private Dialog<String> createImageDialog() {
394
    final var path = getActiveTextEditor().getPath();
395
    final var parentDir = path.getParent();
396
    return new ImageDialog( getWindow(), parentDir );
397
  }
398
399
  /**
400
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
401
   * the Markdown AST.
402
   *
403
   * @return An instance containing the link URL and display text.
404
   */
405
  private HyperlinkModel createHyperlinkModel() {
406
    final var context = getMainPane().createProcessorContext();
407
    final var editor = getActiveTextEditor();
408
    final var textArea = editor.getTextArea();
409
    final var selectedText = textArea.getSelectedText();
410
411
    // Convert current paragraph to Markdown nodes.
412
    final var mp = MarkdownProcessor.create( context );
413
    final var p = textArea.getCurrentParagraph();
414
    final var paragraph = textArea.getText( p );
415
    final var node = mp.toNode( paragraph );
416
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
417
    final var link = visitor.process( node );
418
419
    if( link != null ) {
420
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
421
    }
422
423
    return createHyperlinkModel( link, selectedText );
424
  }
425
426
  private HyperlinkModel createHyperlinkModel(
427
    final Link link, final String selection ) {
428
429
    return link == null
430
      ? new HyperlinkModel( selection, "https://localhost" )
431
      : new HyperlinkModel( link );
432
  }
433
434
  public void insert‿heading_1() {
435
    insert‿heading( 1 );
436
  }
437
438
  public void insert‿heading_2() {
439
    insert‿heading( 2 );
440
  }
441
442
  public void insert‿heading_3() {
443
    insert‿heading( 3 );
444
  }
445
446
  private void insert‿heading( final int level ) {
447
    getActiveTextEditor().heading( level );
448
  }
449
450
  public void insert‿unordered_list() {
451
    getActiveTextEditor().unorderedList();
452
  }
453
454
  public void insert‿ordered_list() {
455
    getActiveTextEditor().orderedList();
456
  }
457
458
  public void insert‿horizontal_rule() {
459
    getActiveTextEditor().horizontalRule();
460
  }
461
462
  public void definition‿create() {
463
    getActiveTextDefinition().createDefinition();
464
  }
465
466
  public void definition‿rename() {
467
    getActiveTextDefinition().renameDefinition();
468
  }
469
470
  public void definition‿delete() {
471
    getActiveTextDefinition().deleteDefinitions();
472
  }
473
474
  public void definition‿autoinsert() {
475
    getMainPane().autoinsert();
476
  }
477
478
  public void view‿refresh() {
479
    getMainPane().viewRefresh();
480
  }
481
482
  public void view‿preview() {
483
    getMainPane().viewPreview();
484
  }
485
486
  public void view‿outline() {
487
    getMainPane().viewOutline();
488
  }
489
490
  public void view‿files() { getMainPane().viewFiles(); }
491
492
  public void view‿statistics() {
493
    getMainPane().viewStatistics();
494
  }
495
496
  public void view‿menubar() {
497
    getMainScene().toggleMenuBar();
498
  }
499
500
  public void view‿toolbar() {
501
    getMainScene().toggleToolBar();
502
  }
503
504
  public void view‿statusbar() {
505
    getMainScene().toggleStatusBar();
506
  }
507
508
  public void view‿log() {
509
    mLogView.view();
510
  }
511
512
  public void help‿about() {
66
public final class ApplicationActions {
67
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
68
69
  private static final String STYLE_SEARCH = "search";
70
71
  /**
72
   * Sci-fi genres, which are can be longer than other genres, typically fall
73
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
74
   * memory when concatenating files together when exporting novels.
75
   */
76
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
77
78
  /**
79
   * When an action is executed, this is one of the recipients.
80
   */
81
  private final MainPane mMainPane;
82
83
  private final MainScene mMainScene;
84
85
  private final LogView mLogView;
86
87
  /**
88
   * Tracks finding text in the active document.
89
   */
90
  private final SearchModel mSearchModel;
91
92
  public ApplicationActions( final MainScene scene, final MainPane pane ) {
93
    mMainScene = scene;
94
    mMainPane = pane;
95
    mLogView = new LogView();
96
    mSearchModel = new SearchModel();
97
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
98
      final var editor = getActiveTextEditor();
99
100
      // Clear highlighted areas before highlighting a new region.
101
      if( o != null ) {
102
        editor.unstylize( STYLE_SEARCH );
103
      }
104
105
      if( n != null ) {
106
        editor.moveTo( n.getStart() );
107
        editor.stylize( n, STYLE_SEARCH );
108
      }
109
    } );
110
111
    // When the active text editor changes, update the haystack.
112
    mMainPane.activeTextEditorProperty().addListener(
113
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
114
    );
115
  }
116
117
  public void file_new() {
118
    getMainPane().newTextEditor();
119
  }
120
121
  public void file_open() {
122
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
123
  }
124
125
  public void file_close() {
126
    getMainPane().close();
127
  }
128
129
  public void file_close_all() {
130
    getMainPane().closeAll();
131
  }
132
133
  public void file_save() {
134
    getMainPane().save();
135
  }
136
137
  public void file_save_as() {
138
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
139
  }
140
141
  public void file_save_all() {
142
    getMainPane().saveAll();
143
  }
144
145
  /**
146
   * Converts the actively edited file in the given file format.
147
   *
148
   * @param format The destination file format.
149
   */
150
  private void file_export( final ExportFormat format ) {
151
    file_export( format, false );
152
  }
153
154
  /**
155
   * Converts one or more files into the given file format. If {@code dir}
156
   * is set to true, this will first append all files in the same directory
157
   * as the actively edited file.
158
   *
159
   * @param format The destination file format.
160
   * @param dir    Export all files in the actively edited file's directory.
161
   */
162
  private void file_export( final ExportFormat format, final boolean dir ) {
163
    final var main = getMainPane();
164
    final var editor = main.getActiveTextEditor();
165
    final var filename = format.toExportFilename( editor.getPath() );
166
    final var selection = pickFiles( filename, FILE_EXPORT );
167
168
    selection.ifPresent( ( files ) -> {
169
      final var file = files.get( 0 );
170
      final var path = file.toPath();
171
      final var document = dir ? append( editor ) : editor.getText();
172
      final var context = main.createProcessorContext( path, format );
173
174
      final var task = new Task<Path>() {
175
        @Override
176
        protected Path call() throws Exception {
177
          final var chain = createProcessors( context );
178
          final var export = chain.apply( document );
179
180
          // Processors can export binary files. In such cases, processors
181
          // return null to prevent further processing.
182
          return export == null ? null : writeString( path, export );
183
        }
184
      };
185
186
      task.setOnSucceeded(
187
        e -> {
188
          final var result = task.getValue();
189
190
          // Binary formats must notify users of success independently.
191
          if( result != null ) {
192
            clue( "Main.status.export.success", result );
193
          }
194
        }
195
      );
196
197
      task.setOnFailed( e -> {
198
        final var ex = task.getException();
199
        clue( ex );
200
201
        if( ex instanceof TypeNotPresentException ) {
202
          fireExportFailedEvent();
203
        }
204
      } );
205
206
      sExecutor.execute( task );
207
    } );
208
  }
209
210
  /**
211
   * @param dir {@code true} means to export all files in the active file
212
   *            editor's directory; {@code false} means to export only the
213
   *            actively edited file.
214
   */
215
  private void file_export_pdf( final boolean dir ) {
216
    final var workspace = getWorkspace();
217
    final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
218
    final var theme = workspace.stringProperty(
219
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
220
221
    if( Typesetter.canRun() ) {
222
      // If the typesetter is installed, allow the user to select a theme. If
223
      // the themes aren't installed, a status message will appear.
224
      if( ThemePicker.choose( themes, theme ) ) {
225
        file_export( APPLICATION_PDF, dir );
226
      }
227
    }
228
    else {
229
      fireExportFailedEvent();
230
    }
231
  }
232
233
  public void file_export_pdf() {
234
    file_export_pdf( false );
235
  }
236
237
  public void file_export_pdf_dir() {
238
    file_export_pdf( true );
239
  }
240
241
  public void file_export_html_svg() {
242
    file_export( HTML_TEX_SVG );
243
  }
244
245
  public void file_export_html_tex() {
246
    file_export( HTML_TEX_DELIMITED );
247
  }
248
249
  public void file_export_xhtml_tex() {
250
    file_export( XHTML_TEX );
251
  }
252
253
  public void file_export_markdown() {
254
    file_export( MARKDOWN_PLAIN );
255
  }
256
257
  private void fireExportFailedEvent() {
258
    runLater( ExportFailedEvent::fireExportFailedEvent );
259
  }
260
261
  public void file_exit() {
262
    final var window = getWindow();
263
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
264
  }
265
266
  public void edit_undo() {
267
    getActiveTextEditor().undo();
268
  }
269
270
  public void edit_redo() {
271
    getActiveTextEditor().redo();
272
  }
273
274
  public void edit_cut() {
275
    getActiveTextEditor().cut();
276
  }
277
278
  public void edit_copy() {
279
    getActiveTextEditor().copy();
280
  }
281
282
  public void edit_paste() {
283
    getActiveTextEditor().paste();
284
  }
285
286
  public void edit_select_all() {
287
    getActiveTextEditor().selectAll();
288
  }
289
290
  public void edit_find() {
291
    final var nodes = getMainScene().getStatusBar().getLeftItems();
292
293
    if( nodes.isEmpty() ) {
294
      final var searchBar = new SearchBar();
295
296
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
297
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
298
299
      searchBar.setOnCancelAction( ( event ) -> {
300
        final var editor = getActiveTextEditor();
301
        nodes.remove( searchBar );
302
        editor.unstylize( STYLE_SEARCH );
303
        editor.getNode().requestFocus();
304
      } );
305
306
      searchBar.addInputListener( ( c, o, n ) -> {
307
        if( n != null && !n.isEmpty() ) {
308
          mSearchModel.search( n, getActiveTextEditor().getText() );
309
        }
310
      } );
311
312
      searchBar.setOnNextAction( ( event ) -> edit_find_next() );
313
      searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
314
315
      nodes.add( searchBar );
316
      searchBar.requestFocus();
317
    }
318
    else {
319
      nodes.clear();
320
    }
321
  }
322
323
  public void edit_find_next() {
324
    mSearchModel.advance();
325
  }
326
327
  public void edit_find_prev() {
328
    mSearchModel.retreat();
329
  }
330
331
  public void edit_preferences() {
332
    try {
333
      new PreferencesController( getWorkspace() ).show();
334
    } catch( final Exception ex ) {
335
      clue( ex );
336
    }
337
  }
338
339
  public void format_bold() {
340
    getActiveTextEditor().bold();
341
  }
342
343
  public void format_italic() {
344
    getActiveTextEditor().italic();
345
  }
346
347
  public void format_monospace() {
348
    getActiveTextEditor().monospace();
349
  }
350
351
  public void format_superscript() {
352
    getActiveTextEditor().superscript();
353
  }
354
355
  public void format_subscript() {
356
    getActiveTextEditor().subscript();
357
  }
358
359
  public void format_strikethrough() {
360
    getActiveTextEditor().strikethrough();
361
  }
362
363
  public void insert_blockquote() {
364
    getActiveTextEditor().blockquote();
365
  }
366
367
  public void insert_code() {
368
    getActiveTextEditor().code();
369
  }
370
371
  public void insert_fenced_code_block() {
372
    getActiveTextEditor().fencedCodeBlock();
373
  }
374
375
  public void insert_link() {
376
    insertObject( createLinkDialog() );
377
  }
378
379
  public void insert_image() {
380
    insertObject( createImageDialog() );
381
  }
382
383
  private void insertObject( final Dialog<String> dialog ) {
384
    final var textArea = getActiveTextEditor().getTextArea();
385
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
386
  }
387
388
  private Dialog<String> createLinkDialog() {
389
    return new LinkDialog( getWindow(), createHyperlinkModel() );
390
  }
391
392
  private Dialog<String> createImageDialog() {
393
    final var path = getActiveTextEditor().getPath();
394
    final var parentDir = path.getParent();
395
    return new ImageDialog( getWindow(), parentDir );
396
  }
397
398
  /**
399
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
400
   * the Markdown AST.
401
   *
402
   * @return An instance containing the link URL and display text.
403
   */
404
  private HyperlinkModel createHyperlinkModel() {
405
    final var context = getMainPane().createProcessorContext();
406
    final var editor = getActiveTextEditor();
407
    final var textArea = editor.getTextArea();
408
    final var selectedText = textArea.getSelectedText();
409
410
    // Convert current paragraph to Markdown nodes.
411
    final var mp = MarkdownProcessor.create( context );
412
    final var p = textArea.getCurrentParagraph();
413
    final var paragraph = textArea.getText( p );
414
    final var node = mp.toNode( paragraph );
415
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
416
    final var link = visitor.process( node );
417
418
    if( link != null ) {
419
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
420
    }
421
422
    return createHyperlinkModel( link, selectedText );
423
  }
424
425
  private HyperlinkModel createHyperlinkModel(
426
    final Link link, final String selection ) {
427
428
    return link == null
429
      ? new HyperlinkModel( selection, "https://localhost" )
430
      : new HyperlinkModel( link );
431
  }
432
433
  public void insert_heading_1() {
434
    insert_heading( 1 );
435
  }
436
437
  public void insert_heading_2() {
438
    insert_heading( 2 );
439
  }
440
441
  public void insert_heading_3() {
442
    insert_heading( 3 );
443
  }
444
445
  private void insert_heading( final int level ) {
446
    getActiveTextEditor().heading( level );
447
  }
448
449
  public void insert_unordered_list() {
450
    getActiveTextEditor().unorderedList();
451
  }
452
453
  public void insert_ordered_list() {
454
    getActiveTextEditor().orderedList();
455
  }
456
457
  public void insert_horizontal_rule() {
458
    getActiveTextEditor().horizontalRule();
459
  }
460
461
  public void definition_create() {
462
    getActiveTextDefinition().createDefinition();
463
  }
464
465
  public void definition_rename() {
466
    getActiveTextDefinition().renameDefinition();
467
  }
468
469
  public void definition_delete() {
470
    getActiveTextDefinition().deleteDefinitions();
471
  }
472
473
  public void definition_autoinsert() {
474
    getMainPane().autoinsert();
475
  }
476
477
  public void view_refresh() {
478
    getMainPane().viewRefresh();
479
  }
480
481
  public void view_preview() {
482
    getMainPane().viewPreview();
483
  }
484
485
  public void view_outline() {
486
    getMainPane().viewOutline();
487
  }
488
489
  public void view_files() { getMainPane().viewFiles(); }
490
491
  public void view_statistics() {
492
    getMainPane().viewStatistics();
493
  }
494
495
  public void view_menubar() {
496
    getMainScene().toggleMenuBar();
497
  }
498
499
  public void view_toolbar() {
500
    getMainScene().toggleToolBar();
501
  }
502
503
  public void view_statusbar() {
504
    getMainScene().toggleStatusBar();
505
  }
506
507
  public void view_log() {
508
    mLogView.view();
509
  }
510
511
  public void help_about() {
513512
    final var alert = new Alert( INFORMATION );
514513
    final var prefix = "Dialog.about.";
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
4545
    createMenu(
4646
      get( "Main.menu.file" ),
47
      addAction( "file.new", e -> actions.file‿new() ),
48
      addAction( "file.open", e -> actions.file‿open() ),
47
      addAction( "file.new", e -> actions.file_new() ),
48
      addAction( "file.open", e -> actions.file_open() ),
4949
      SEPARATOR_ACTION,
50
      addAction( "file.close", e -> actions.file‿close() ),
51
      addAction( "file.close_all", e -> actions.file‿close_all() ),
50
      addAction( "file.close", e -> actions.file_close() ),
51
      addAction( "file.close_all", e -> actions.file_close_all() ),
5252
      SEPARATOR_ACTION,
53
      addAction( "file.save", e -> actions.file‿save() ),
54
      addAction( "file.save_as", e -> actions.file‿save_as() ),
55
      addAction( "file.save_all", e -> actions.file‿save_all() ),
53
      addAction( "file.save", e -> actions.file_save() ),
54
      addAction( "file.save_as", e -> actions.file_save_as() ),
55
      addAction( "file.save_all", e -> actions.file_save_all() ),
5656
      SEPARATOR_ACTION,
5757
      addAction( "file.export", e -> {} )
5858
        .addSubActions(
59
          addAction( "file.export.pdf", e -> actions.file‿export‿pdf() ),
60
          addAction( "file.export.pdf.dir", e -> actions.file‿export‿pdf‿dir() ),
61
          addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ),
62
          addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ),
63
          addAction( "file.export.xhtml_tex", e -> actions.file‿export‿xhtml_tex() ),
64
          addAction( "file.export.markdown", e -> actions.file‿export‿markdown() )
59
          addAction( "file.export.pdf", e -> actions.file_export_pdf() ),
60
          addAction( "file.export.pdf.dir", e -> actions.file_export_pdf_dir() ),
61
          addAction( "file.export.html_svg", e -> actions.file_export_html_svg() ),
62
          addAction( "file.export.html_tex", e -> actions.file_export_html_tex() ),
63
          addAction( "file.export.xhtml_tex", e -> actions.file_export_xhtml_tex() ),
64
          addAction( "file.export.markdown", e -> actions.file_export_markdown() )
6565
        ),
6666
      SEPARATOR_ACTION,
67
      addAction( "file.exit", e -> actions.file‿exit() )
67
      addAction( "file.exit", e -> actions.file_exit() )
6868
    ),
6969
    createMenu(
7070
      get( "Main.menu.edit" ),
7171
      SEPARATOR_ACTION,
72
      addAction( "edit.undo", e -> actions.edit‿undo() ),
73
      addAction( "edit.redo", e -> actions.edit‿redo() ),
72
      addAction( "edit.undo", e -> actions.edit_undo() ),
73
      addAction( "edit.redo", e -> actions.edit_redo() ),
7474
      SEPARATOR_ACTION,
75
      addAction( "edit.cut", e -> actions.edit‿cut() ),
76
      addAction( "edit.copy", e -> actions.edit‿copy() ),
77
      addAction( "edit.paste", e -> actions.edit‿paste() ),
78
      addAction( "edit.select_all", e -> actions.edit‿select_all() ),
75
      addAction( "edit.cut", e -> actions.edit_cut() ),
76
      addAction( "edit.copy", e -> actions.edit_copy() ),
77
      addAction( "edit.paste", e -> actions.edit_paste() ),
78
      addAction( "edit.select_all", e -> actions.edit_select_all() ),
7979
      SEPARATOR_ACTION,
80
      addAction( "edit.find", e -> actions.edit‿find() ),
81
      addAction( "edit.find_next", e -> actions.edit‿find_next() ),
82
      addAction( "edit.find_prev", e -> actions.edit‿find_prev() ),
80
      addAction( "edit.find", e -> actions.edit_find() ),
81
      addAction( "edit.find_next", e -> actions.edit_find_next() ),
82
      addAction( "edit.find_prev", e -> actions.edit_find_prev() ),
8383
      SEPARATOR_ACTION,
84
      addAction( "edit.preferences", e -> actions.edit‿preferences() )
84
      addAction( "edit.preferences", e -> actions.edit_preferences() )
8585
    ),
8686
    createMenu(
8787
      get( "Main.menu.format" ),
88
      addAction( "format.bold", e -> actions.format‿bold() ),
89
      addAction( "format.italic", e -> actions.format‿italic() ),
90
      addAction( "format.monospace", e -> actions.format‿monospace() ),
91
      addAction( "format.superscript", e -> actions.format‿superscript() ),
92
      addAction( "format.subscript", e -> actions.format‿subscript() ),
93
      addAction( "format.strikethrough", e -> actions.format‿strikethrough() )
88
      addAction( "format.bold", e -> actions.format_bold() ),
89
      addAction( "format.italic", e -> actions.format_italic() ),
90
      addAction( "format.monospace", e -> actions.format_monospace() ),
91
      addAction( "format.superscript", e -> actions.format_superscript() ),
92
      addAction( "format.subscript", e -> actions.format_subscript() ),
93
      addAction( "format.strikethrough", e -> actions.format_strikethrough() )
9494
    ),
9595
    createMenu(
9696
      get( "Main.menu.insert" ),
97
      addAction( "insert.blockquote", e -> actions.insert‿blockquote() ),
98
      addAction( "insert.code", e -> actions.insert‿code() ),
99
      addAction( "insert.fenced_code_block", e -> actions.insert‿fenced_code_block() ),
97
      addAction( "insert.blockquote", e -> actions.insert_blockquote() ),
98
      addAction( "insert.code", e -> actions.insert_code() ),
99
      addAction( "insert.fenced_code_block", e -> actions.insert_fenced_code_block() ),
100100
      SEPARATOR_ACTION,
101
      addAction( "insert.link", e -> actions.insert‿link() ),
102
      addAction( "insert.image", e -> actions.insert‿image() ),
101
      addAction( "insert.link", e -> actions.insert_link() ),
102
      addAction( "insert.image", e -> actions.insert_image() ),
103103
      SEPARATOR_ACTION,
104
      addAction( "insert.heading_1", e -> actions.insert‿heading_1() ),
105
      addAction( "insert.heading_2", e -> actions.insert‿heading_2() ),
106
      addAction( "insert.heading_3", e -> actions.insert‿heading_3() ),
104
      addAction( "insert.heading_1", e -> actions.insert_heading_1() ),
105
      addAction( "insert.heading_2", e -> actions.insert_heading_2() ),
106
      addAction( "insert.heading_3", e -> actions.insert_heading_3() ),
107107
      SEPARATOR_ACTION,
108
      addAction( "insert.unordered_list", e -> actions.insert‿unordered_list() ),
109
      addAction( "insert.ordered_list", e -> actions.insert‿ordered_list() ),
110
      addAction( "insert.horizontal_rule", e -> actions.insert‿horizontal_rule() )
108
      addAction( "insert.unordered_list", e -> actions.insert_unordered_list() ),
109
      addAction( "insert.ordered_list", e -> actions.insert_ordered_list() ),
110
      addAction( "insert.horizontal_rule", e -> actions.insert_horizontal_rule() )
111111
    ),
112112
    createMenu(
113113
      get( "Main.menu.definition" ),
114
      addAction( "definition.insert", e -> actions.definition‿autoinsert() ),
114
      addAction( "definition.insert", e -> actions.definition_autoinsert() ),
115115
      SEPARATOR_ACTION,
116
      addAction( "definition.create", e -> actions.definition‿create() ),
117
      addAction( "definition.rename", e -> actions.definition‿rename() ),
118
      addAction( "definition.delete", e -> actions.definition‿delete() )
116
      addAction( "definition.create", e -> actions.definition_create() ),
117
      addAction( "definition.rename", e -> actions.definition_rename() ),
118
      addAction( "definition.delete", e -> actions.definition_delete() )
119119
    ),
120120
    createMenu(
121121
      get( "Main.menu.view" ),
122
      addAction( "view.refresh", e -> actions.view‿refresh() ),
122
      addAction( "view.refresh", e -> actions.view_refresh() ),
123123
      SEPARATOR_ACTION,
124
      addAction( "view.preview", e -> actions.view‿preview() ),
125
      addAction( "view.outline", e -> actions.view‿outline() ),
126
      addAction( "view.statistics", e-> actions.view‿statistics() ),
127
      addAction( "view.files", e-> actions.view‿files() ),
124
      addAction( "view.preview", e -> actions.view_preview() ),
125
      addAction( "view.outline", e -> actions.view_outline() ),
126
      addAction( "view.statistics", e-> actions.view_statistics() ),
127
      addAction( "view.files", e-> actions.view_files() ),
128128
      SEPARATOR_ACTION,
129
      addAction( "view.menubar", e -> actions.view‿menubar() ),
130
      addAction( "view.toolbar", e -> actions.view‿toolbar() ),
131
      addAction( "view.statusbar", e -> actions.view‿statusbar() ),
129
      addAction( "view.menubar", e -> actions.view_menubar() ),
130
      addAction( "view.toolbar", e -> actions.view_toolbar() ),
131
      addAction( "view.statusbar", e -> actions.view_statusbar() ),
132132
      SEPARATOR_ACTION,
133
      addAction( "view.log", e -> actions.view‿log() )
133
      addAction( "view.log", e -> actions.view_log() )
134134
    ),
135135
    createMenu(
136136
      get( "Main.menu.help" ),
137
      addAction( "help.about", e -> actions.help‿about() )
137
      addAction( "help.about", e -> actions.help_about() )
138138
    ) );
139139
    //@formatter:on
D src/main/java/com/keenwrite/ui/actions/FileChooserCommand.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.actions;
3
4
import com.keenwrite.Messages;
5
import com.keenwrite.io.FileType;
6
import com.keenwrite.service.Settings;
7
import javafx.beans.property.Property;
8
import javafx.stage.FileChooser;
9
import javafx.stage.FileChooser.ExtensionFilter;
10
import javafx.stage.Window;
11
12
import java.io.File;
13
import java.util.ArrayList;
14
import java.util.List;
15
import java.util.Optional;
16
17
import static com.keenwrite.constants.Constants.*;
18
import static com.keenwrite.Messages.get;
19
import static com.keenwrite.io.FileType.*;
20
import static java.lang.String.format;
21
22
/**
23
 * Responsible for opening a dialog that provides users with the ability to
24
 * select files.
25
 */
26
public final class FileChooserCommand {
27
  private static final String FILTER_EXTENSION_TITLES =
28
    "Dialog.file.choose.filter";
29
30
  /**
31
   * Dialog owner.
32
   */
33
  private final Window mParent;
34
35
  /**
36
   * Set to the directory of most recently selected file.
37
   */
38
  private final Property<File> mDirectory;
39
40
  /**
41
   * Constructs a new {@link FileChooserCommand} that will attach to a given
42
   * parent window and update the given property upon a successful selection.
43
   *
44
   * @param parent    The parent window that will own the dialog.
45
   * @param directory The most recently opened file's directory property.
46
   */
47
  public FileChooserCommand(
48
    final Window parent, final Property<File> directory ) {
49
    mParent = parent;
50
    mDirectory = directory;
51
  }
52
53
  /**
54
   * Returns a list of files to be opened.
55
   *
56
   * @return A non-null, possibly empty list of files to open.
57
   */
58
  public List<File> openFiles() {
59
    final var dialog = createFileChooser(
60
      "Dialog.file.choose.open.title" );
61
    final var list = dialog.showOpenMultipleDialog( mParent );
62
    final List<File> selected = list == null ? List.of() : list;
63
    final var files = new ArrayList<File>( selected.size() );
64
65
    files.addAll( selected );
66
67
    if( !files.isEmpty() ) {
68
      setRecentDirectory( files.get( 0 ) );
69
    }
70
71
    return files;
72
  }
73
74
  /**
75
   * Allows saving the document under a new file name.
76
   *
77
   * @return The new file name.
78
   */
79
  public Optional<File> saveAs() {
80
    final var dialog = createFileChooser( "Dialog.file.choose.save.title" );
81
    return saveOrExportAs( dialog );
82
  }
83
84
  /**
85
   * Allows exporting the document to a new file format.
86
   *
87
   * @return The file name for exporting into.
88
   */
89
  public Optional<File> exportAs( final File filename ) {
90
    final var dialog = createFileChooser( "Dialog.file.choose.export.title" );
91
    dialog.setInitialFileName( filename.getName() );
92
    return saveOrExportAs( dialog );
93
  }
94
95
  /**
96
   * Helper method called when saving or exporting.
97
   *
98
   * @param dialog The {@link FileChooser} to display.
99
   * @return The file selected by the user.
100
   */
101
  private Optional<File> saveOrExportAs( final FileChooser dialog ) {
102
    final var file = dialog.showSaveDialog( mParent );
103
104
    setRecentDirectory( file );
105
106
    return Optional.ofNullable( file );
107
  }
108
109
  /**
110
   * Opens a new {@link FileChooser} at the previously selected directory.
111
   * If the initial directory is missing, this will attempt to default to
112
   * the user's home directory. If the home directory is missing, this will
113
   * use whatever JavaFX chooses for the initial directory. Without such an
114
   * intervention, an {@link IllegalArgumentException} would be thrown.
115
   *
116
   * @param key Message key from resource bundle.
117
   * @return {@link FileChooser} GUI allowing the user to pick a file.
118
   */
119
  private FileChooser createFileChooser( final String key ) {
120
    final var prefDir = mDirectory.getValue();
121
    final var openDir = prefDir.isDirectory() ? prefDir : USER_DIRECTORY;
122
    final var chooser = new FileChooser();
123
124
    chooser.setTitle( get( key ) );
125
    chooser.getExtensionFilters().addAll( createExtensionFilters() );
126
    chooser.setInitialDirectory( openDir.isDirectory() ? openDir : null );
127
128
    return chooser;
129
  }
130
131
  private List<ExtensionFilter> createExtensionFilters() {
132
    final List<ExtensionFilter> list = new ArrayList<>();
133
134
    // TODO: Return a list of all properties that match the filter prefix.
135
    // This will allow dynamic filters to be added and removed just by
136
    // updating the properties file.
137
    list.add( createExtensionFilter( ALL ) );
138
    list.add( createExtensionFilter( SOURCE ) );
139
    list.add( createExtensionFilter( DEFINITION ) );
140
    list.add( createExtensionFilter( XML ) );
141
142
    return list;
143
  }
144
145
  /**
146
   * Returns a filter for file name extensions recognized by the application
147
   * that can be opened by the user.
148
   *
149
   * @param filetype Used to find the globbing pattern for extensions.
150
   * @return A file name filter suitable for use by a FileDialog instance.
151
   */
152
  private ExtensionFilter createExtensionFilter(
153
    final FileType filetype ) {
154
    final var tKey = format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype );
155
    final var eKey = format( "%s.%s", GLOB_PREFIX_FILE, filetype );
156
157
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
158
  }
159
160
  /**
161
   * Sets the value for the most recent directly selected. This will get the
162
   * parent location from the given file. If the parent is a readable directory
163
   * then this will update the most recent directory property.
164
   *
165
   * @param file A file contained in a directory.
166
   */
167
  private void setRecentDirectory( final File file ) {
168
    if( file != null ) {
169
      final var parent = file.getParentFile();
170
      final var dir = parent == null ? USER_DIRECTORY : parent;
171
172
      if( dir.isDirectory() && dir.canRead() ) {
173
        mDirectory.setValue( dir );
174
      }
175
    }
176
  }
177
178
  private List<String> getExtensions( final String key ) {
179
    return getSettings().getStringSettingList( key );
180
  }
181
182
  private static Settings getSettings() {
183
    return sSettings;
184
  }
185
}
1861
M src/main/java/com/keenwrite/ui/dialogs/ThemePicker.java
77
import javafx.scene.control.ChoiceDialog;
88
import javafx.scene.control.ComboBox;
9
import javafx.scene.image.Image;
910
import javafx.scene.input.KeyCode;
1011
import javafx.stage.Stage;
...
6465
  private void initIcon() {
6566
    setGraphic( ICON_DIALOG_NODE );
67
    setStageGraphic( ICON_DIALOG );
68
  }
6669
67
    final var window = getDialogPane().getScene().getWindow();
68
    if( window instanceof Stage ) {
69
      ((Stage) window).getIcons().add( ICON_DIALOG );
70
  @SuppressWarnings( "SameParameterValue" )
71
  private void setStageGraphic( final Image icon ) {
72
    if( getDialogPane().getScene().getWindow() instanceof final Stage stage ) {
73
      stage.getIcons().add( icon );
7074
    }
7175
  }
M src/main/java/com/keenwrite/ui/explorer/FilePickerFactory.java
9696
      }
9797
98
      //mBuilder.setTitle( get(title) );
98
      //mBuilder.setTitle( get( title ) );
9999
      mBuilder.setAction( action );
100100
    }
101101
102102
    @Override
103103
    public void setInitialDirectory( final Path path ) {
104104
      mBuilder.setInitialDirectory( path );
105105
    }
106
107
//    private JWFileChooserFilterType createFileFilters() {
108
//      final var filters = new JWFilterGlobFactory();
109
//
110
//      return filters.create( "PDF Files" )
111
//                    .addRule( INCLUDE, "**/*.pdf" )
112
//                    .addRule( EXCLUDE_AND_HALT, "**/.*" )
113
//                    .build();
114
//    }
106115
107116
    @Override
M src/main/java/com/keenwrite/ui/tree/AltTreeCell.java
4141
      }
4242
43
      if( n instanceof TextField ) {
43
      if( n instanceof final TextField input ) {
4444
        n.addEventFilter( KEY_RELEASED, mKeyHandler );
45
        final var input = (TextField) n;
4645
        mInputText.bind( input.textProperty() );
4746
        mFocusListener = new FocusListener( input );
M src/main/resources/com/keenwrite/preview/webview.css
229229
}
230230
231
/* EMAIL ***/
232
div.email {
233
  padding: 0 1.5em;
234
  text-align: left;
235
  text-indent: 0;
236
  border-style: solid;
237
  border-width: 0.05em;
238
  border-radius: .25em;
239
  background-color: #f8f8f8;
240
}
241
M src/test/java/com/keenwrite/io/MediaTypeTest.java
3737
      "md", TEXT_MARKDOWN,
3838
      "Rmd", TEXT_R_MARKDOWN,
39
      "Rxml", TEXT_R_XML,
4039
      "txt", TEXT_PLAIN,
4140
      "yml", TEXT_YAML