| 3 | 3 | See the following documents for more information: |
| 4 | 4 | |
| 5 | * [definitions.md](definitions.md) -- Definitions and interpolation | |
| 5 | * [variables.md](variables.md) -- Variable definitions and interpolation | |
| 6 | 6 | * [r.md](r.md) -- Call R functions within R Markdown documents |
| 7 | 7 | * [svg.md](svg.md) -- Fix known issues with displaying SVG files |
| 1 | # Introduction | |
| 2 | ||
| 3 | This document describes how to use the application. | |
| 4 | ||
| 5 | # Variable definitions | |
| 6 | ||
| 7 | Variable definitions provide a way to insert key names having associated values into a document. The variable names and values are declared inside an external file using the [YAML](http://www.yaml.org/) file format. Simply put, variables are written in the file as follows: | |
| 8 | ||
| 9 | ``` | |
| 10 | key: value | |
| 11 | ``` | |
| 12 | ||
| 13 | Any number of variables can be defined, in any order: | |
| 14 | ||
| 15 | ``` | |
| 16 | key_1: Value 1 | |
| 17 | key_2: Value 2 | |
| 18 | ``` | |
| 19 | ||
| 20 | Variables can reference other variables by bookending the key name within symbols: | |
| 21 | ||
| 22 | ``` | |
| 23 | key: Value | |
| 24 | key_1: {{key}} 1 | |
| 25 | key_2: {{key}} 2 | |
| 26 | ``` | |
| 27 | ||
| 28 | Variables can use a nested structure to help group related information: | |
| 29 | ||
| 30 | ``` | |
| 31 | novel: | |
| 32 | title: Book Title | |
| 33 | author: Author Name | |
| 34 | isbn: 978-3-16-148410-0 | |
| 35 | ``` | |
| 36 | ||
| 37 | Use a period to reference nested keys, such as: | |
| 38 | ||
| 39 | ``` | |
| 40 | novel: | |
| 41 | author: Author Name | |
| 42 | copyright: | |
| 43 | owner: {{novel.author}} | |
| 44 | ``` | |
| 45 | ||
| 46 | Save the variable definitions in a file having an extension of `.yaml` or `.yml`. | |
| 47 | ||
| 48 | # Document editing | |
| 49 | ||
| 50 | The application's purpose is to completely separate the document's content from its presentation. To achieve this, documents are composed using a [plain text](http://spec.commonmark.org/0.28/) format. | |
| 51 | ||
| 52 | ## Create document | |
| 53 | ||
| 54 | Start a new document as follows: | |
| 55 | ||
| 56 | 1. Start the application. | |
| 57 | 1. Click **File → New** to create an empty document to edit. | |
| 58 | 1. Click **File → Open** to open a variable definition file. | |
| 59 | 1. Change **Source Files** to **Definition Files** to list definition files. | |
| 60 | 1. Browse to and select a file saved with a `.yaml` or `.yml` extension. | |
| 61 | 1. Click **Open**. | |
| 62 | ||
| 63 | The variable definitions appear in the variable definition pane under the heading of **Definitions**. | |
| 64 | ||
| 65 | ## Edit document | |
| 66 | ||
| 67 | Edit the document as normal. Notice how the preview pane updates as new content is added. The toolbar shows various icons that perform different formatting operations. Try them to see how they appear in the preview pane. Other operations not shown on the toolbar include: | |
| 68 | ||
| 69 | * Struck text (enclose the words within `~~` and `~~`) | |
| 70 | * Horizontal rule (use `---` on an otherwise empty line). | |
| 71 | ||
| 72 | The preview pane shows one way to interpret and format the document, but many other presentations are possible. | |
| 73 | ||
| 74 | ## Insert variable | |
| 75 | ||
| 76 | Let's assume that the variable definitions loaded into the application include: | |
| 77 | ||
| 78 | ``` | |
| 79 | novel: | |
| 80 | title: Diary of {{novel.author}} | |
| 81 | author: Anne Frank | |
| 82 | ``` | |
| 83 | ||
| 84 | To reference a variable, type in the key name enclosed within double braces, such as: | |
| 85 | ||
| 86 | ``` | |
| 87 | The novel "{{novel.title}}" is one of the most widely read books in the world. | |
| 88 | ``` | |
| 89 | ||
| 90 | The preview pane shows: | |
| 91 | ||
| 92 | > The novel "Diary of Anne Frank" is one of the most widely read books in the world. | |
| 93 | ||
| 94 | As it is laborious to type in variable names, it is possible to inject the variable name using autocomplete. Accomplish this as follows: | |
| 95 | ||
| 96 | 1. Create a new file. | |
| 97 | 1. Type in a partial variable value, such as **Dia**. | |
| 98 | 1. Press `Ctrl+Space` (hold down the `Control` key and tap the spacebar). | |
| 99 | ||
| 100 | The editor shows: | |
| 101 | ||
| 102 | ``` | |
| 103 | {{novel.title}} | |
| 104 | ``` | |
| 105 | ||
| 106 | The preview pane shows: | |
| 107 | ||
| 108 | ``` | |
| 109 | Diary of Anne Frank | |
| 110 | ``` | |
| 111 | ||
| 112 | The variable name is inserted into the document and the preview pane shows the variable's value. | |
| 113 | ||
| 114 | 1 |
| 110 | 110 | working directory where the R engine searches for source files. |
| 111 | 111 | |
| 112 | # YAML definitions | |
| 112 | # YAML variable definitions | |
| 113 | 113 | |
| 114 | 114 | To see how variable definitions work in R, try the following: |
| ... | ||
| 123 | 123 | 1. Save the file as `definitions.yaml`. |
| 124 | 124 | 1. Click **File → Open**. |
| 125 | 1. Set **Source Files** to **Definition Files**. | |
| 125 | 1. Set **Source Files** to **Variable Files**. | |
| 126 | 126 | 1. Select `definitions.yaml`. |
| 127 | 127 | 1. Click **Open**. |
| ... | ||
| 142 | 142 | ``` |
| 143 | 143 | |
| 144 | This is because the application inserts definition reference names based | |
| 144 | This is because the application inserts variable reference names based | |
| 145 | 145 | on the type of file being edited. By default, the R engine does not have |
| 146 | 146 | a function named `x` defined. |
| ... | ||
| 173 | 173 | |
| 174 | 174 | The `x` function attempts to evaluate the expression defined by the YAML |
| 175 | variable. This means that the YAML definitions can also include expressions | |
| 175 | variable. This means that the YAML variables can also include expressions | |
| 176 | 176 | that R is capable of evaluating. |
| 177 | 177 | |
| 1 | # Introduction | |
| 2 | ||
| 3 | This document describes how to use the application. | |
| 4 | ||
| 5 | # Variable definitions | |
| 6 | ||
| 7 | Variable definitions provide a way to insert key names having associated values into a document. The variable names and values are declared inside an external file using the [YAML](http://www.yaml.org/) file format. Simply put, variables are written in the file as follows: | |
| 8 | ||
| 9 | ``` | |
| 10 | key: value | |
| 11 | ``` | |
| 12 | ||
| 13 | Any number of variables can be defined, in any order: | |
| 14 | ||
| 15 | ``` | |
| 16 | key_1: Value 1 | |
| 17 | key_2: Value 2 | |
| 18 | ``` | |
| 19 | ||
| 20 | Variables can reference other variables by bookending the key name within symbols: | |
| 21 | ||
| 22 | ``` | |
| 23 | key: Value | |
| 24 | key_1: {{key}} 1 | |
| 25 | key_2: {{key}} 2 | |
| 26 | ``` | |
| 27 | ||
| 28 | Variables can use a nested structure to help group related information: | |
| 29 | ||
| 30 | ``` | |
| 31 | novel: | |
| 32 | title: Book Title | |
| 33 | author: Author Name | |
| 34 | isbn: 978-3-16-148410-0 | |
| 35 | ``` | |
| 36 | ||
| 37 | Use a period to reference nested keys, such as: | |
| 38 | ||
| 39 | ``` | |
| 40 | novel: | |
| 41 | author: Author Name | |
| 42 | copyright: | |
| 43 | owner: {{novel.author}} | |
| 44 | ``` | |
| 45 | ||
| 46 | Save the variable definitions in a file having an extension of `.yaml` or `.yml`. | |
| 47 | ||
| 48 | # Document editing | |
| 49 | ||
| 50 | The application's purpose is to completely separate the document's content from its presentation. To achieve this, documents are composed using a [plain text](http://spec.commonmark.org/0.28/) format. | |
| 51 | ||
| 52 | ## Create document | |
| 53 | ||
| 54 | Start a new document as follows: | |
| 55 | ||
| 56 | 1. Start the application. | |
| 57 | 1. Click **File → New** to create an empty document to edit. | |
| 58 | 1. Click **File → Open** to open a variable definition file. | |
| 59 | 1. Change **Source Files** to **Variable Files** to list variable definition files. | |
| 60 | 1. Browse to and select a file saved with a `.yaml` or `.yml` extension. | |
| 61 | 1. Click **Open**. | |
| 62 | ||
| 63 | The variable definitions appear in the variable definition pane under the heading of **Variables**. | |
| 64 | ||
| 65 | ## Edit document | |
| 66 | ||
| 67 | Edit the document as normal. Notice how the preview pane updates as new content is added. The toolbar shows various icons that perform different formatting operations. Try them to see how they appear in the preview pane. Other operations not shown on the toolbar include: | |
| 68 | ||
| 69 | * Struck text (enclose the words within `~~` and `~~`) | |
| 70 | * Horizontal rule (use `---` on an otherwise empty line). | |
| 71 | ||
| 72 | The preview pane shows one way to interpret and format the document, but many other presentations are possible. | |
| 73 | ||
| 74 | ## Insert variable | |
| 75 | ||
| 76 | Let's assume that the variable definitions loaded into the application include: | |
| 77 | ||
| 78 | ``` | |
| 79 | novel: | |
| 80 | title: Diary of {{novel.author}} | |
| 81 | author: Anne Frank | |
| 82 | ``` | |
| 83 | ||
| 84 | To reference a variable, type in the key name enclosed within double braces, such as: | |
| 85 | ||
| 86 | ``` | |
| 87 | The novel "{{novel.title}}" is one of the most widely read books in the world. | |
| 88 | ``` | |
| 89 | ||
| 90 | The preview pane shows: | |
| 91 | ||
| 92 | > The novel "Diary of Anne Frank" is one of the most widely read books in the world. | |
| 93 | ||
| 94 | As it is laborious to type in variable names, it is possible to inject the variable name using autocomplete. Accomplish this as follows: | |
| 95 | ||
| 96 | 1. Create a new file. | |
| 97 | 1. Type in a partial variable value, such as **Dia**. | |
| 98 | 1. Press `Ctrl+Space` (hold down the `Control` key and tap the spacebar). | |
| 99 | ||
| 100 | The editor shows: | |
| 101 | ||
| 102 | ``` | |
| 103 | {{novel.title}} | |
| 104 | ``` | |
| 105 | ||
| 106 | The preview pane shows: | |
| 107 | ||
| 108 | ``` | |
| 109 | Diary of Anne Frank | |
| 110 | ``` | |
| 111 | ||
| 112 | The variable name is inserted into the document and the preview pane shows the variable's value. | |
| 113 | ||
| 1 | 114 |
| 114 | 114 | * Renders the actively selected plain text editor tab. |
| 115 | 115 | */ |
| 116 | private final HtmlPreview mHtmlPreview; | |
| 117 | ||
| 118 | /** | |
| 119 | * Provides an interactive document outline. | |
| 120 | */ | |
| 121 | private final DocumentOutline mDocumentOutline = new DocumentOutline(); | |
| 122 | ||
| 123 | /** | |
| 124 | * Changing the active editor fires the value changed event. This allows | |
| 125 | * refreshes to happen when external definitions are modified and need to | |
| 126 | * trigger the processing chain. | |
| 127 | */ | |
| 128 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 129 | createActiveTextEditor(); | |
| 130 | ||
| 131 | /** | |
| 132 | * Changing the active definition editor fires the value changed event. This | |
| 133 | * allows refreshes to happen when external definitions are modified and need | |
| 134 | * to trigger the processing chain. | |
| 135 | */ | |
| 136 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 137 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 138 | ||
| 139 | /** | |
| 140 | * Tracks the number of detached tab panels opened into their own windows, | |
| 141 | * which allows unique identification of subordinate windows by their title. | |
| 142 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 143 | */ | |
| 144 | private byte mWindowCount; | |
| 145 | ||
| 146 | /** | |
| 147 | * Called when the definition data is changed. | |
| 148 | */ | |
| 149 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 150 | event -> { | |
| 151 | final var editor = mActiveDefinitionEditor.get(); | |
| 152 | ||
| 153 | resolve( editor ); | |
| 154 | process( getActiveTextEditor() ); | |
| 155 | save( editor ); | |
| 156 | }; | |
| 157 | ||
| 158 | /** | |
| 159 | * Adds all content panels to the main user interface. This will load the | |
| 160 | * configuration settings from the workspace to reproduce the settings from | |
| 161 | * a previous session. | |
| 162 | */ | |
| 163 | public MainPane( final Workspace workspace ) { | |
| 164 | mWorkspace = workspace; | |
| 165 | mHtmlPreview = new HtmlPreview( workspace ); | |
| 166 | ||
| 167 | open( bin( getRecentFiles() ) ); | |
| 168 | viewPreview(); | |
| 169 | setDividerPositions( calculateDividerPositions() ); | |
| 170 | ||
| 171 | // Once the main scene's window regains focus, update the active definition | |
| 172 | // editor to the currently selected tab. | |
| 173 | runLater( | |
| 174 | () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 175 | // Order matters here. We want to close all the tabs to ensure each | |
| 176 | // is saved, but after they are closed, the workspace should still | |
| 177 | // retain the list of files that were open. If this line came after | |
| 178 | // closing, then restarting the application would list no files. | |
| 179 | mWorkspace.save(); | |
| 180 | ||
| 181 | if( closeAll() ) { | |
| 182 | Platform.exit(); | |
| 183 | System.exit( 0 ); | |
| 184 | } | |
| 185 | else { | |
| 186 | event.consume(); | |
| 187 | } | |
| 188 | } ) | |
| 189 | ); | |
| 190 | ||
| 191 | register( this ); | |
| 192 | } | |
| 193 | ||
| 194 | @Subscribe | |
| 195 | public void handle( final TextEditorFocusEvent event ) { | |
| 196 | mActiveTextEditor.set( event.get() ); | |
| 197 | } | |
| 198 | ||
| 199 | @Subscribe | |
| 200 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 201 | mActiveDefinitionEditor.set( event.get() ); | |
| 202 | } | |
| 203 | ||
| 204 | /** | |
| 205 | * Typically called when a file name is clicked in the {@link HtmlPanel}. | |
| 206 | * | |
| 207 | * @param event The event to process, must contain a valid file reference. | |
| 208 | */ | |
| 209 | @Subscribe | |
| 210 | public void handle( final FileOpenEvent event ) { | |
| 211 | final File eventFile; | |
| 212 | final var eventUri = event.getUri(); | |
| 213 | ||
| 214 | if( eventUri.isAbsolute() ) { | |
| 215 | eventFile = new File( eventUri.getPath() ); | |
| 216 | } | |
| 217 | else { | |
| 218 | final var activeFile = getActiveTextEditor().getFile(); | |
| 219 | final var parent = activeFile.getParentFile(); | |
| 220 | ||
| 221 | if( parent == null ) { | |
| 222 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 223 | return; | |
| 224 | } | |
| 225 | else { | |
| 226 | final var parentPath = parent.getAbsolutePath(); | |
| 227 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | runLater( () -> open( eventFile ) ); | |
| 232 | } | |
| 233 | ||
| 234 | @Subscribe | |
| 235 | public void handle( final CaretNavigationEvent event ) { | |
| 236 | runLater( () -> { | |
| 237 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 238 | textArea.moveTo( event.getOffset() ); | |
| 239 | textArea.requestFollowCaret(); | |
| 240 | textArea.requestFocus(); | |
| 241 | } ); | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 246 | */ | |
| 247 | private double[] calculateDividerPositions() { | |
| 248 | final var ratio = 100f / getItems().size() / 100; | |
| 249 | final var positions = getDividerPositions(); | |
| 250 | ||
| 251 | for( int i = 0; i < positions.length; i++ ) { | |
| 252 | positions[ i ] = ratio * i; | |
| 253 | } | |
| 254 | ||
| 255 | return positions; | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Opens all the files into the application, provided the paths are unique. | |
| 260 | * This may only be called for any type of files that a user can edit | |
| 261 | * (i.e., update and persist), such as definitions and text files. | |
| 262 | * | |
| 263 | * @param files The list of files to open. | |
| 264 | */ | |
| 265 | public void open( final List<File> files ) { | |
| 266 | files.forEach( this::open ); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * This opens the given file. Since the preview pane is not a file that | |
| 271 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 272 | * | |
| 273 | * @param file The file to open. | |
| 274 | */ | |
| 275 | private void open( final File file ) { | |
| 276 | final var tab = createTab( file ); | |
| 277 | final var node = tab.getContent(); | |
| 278 | final var mediaType = MediaType.valueFrom( file ); | |
| 279 | final var tabPane = obtainTabPane( mediaType ); | |
| 280 | ||
| 281 | tab.setTooltip( createTooltip( file ) ); | |
| 282 | tabPane.setFocusTraversable( false ); | |
| 283 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 284 | tabPane.getTabs().add( tab ); | |
| 285 | ||
| 286 | // Attach the tab scene factory for new tab panes. | |
| 287 | if( !getItems().contains( tabPane ) ) { | |
| 288 | addTabPane( | |
| 289 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 293 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 294 | } | |
| 295 | ||
| 296 | /** | |
| 297 | * Opens a new text editor document using the default document file name. | |
| 298 | */ | |
| 299 | public void newTextEditor() { | |
| 300 | open( DOCUMENT_DEFAULT ); | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Opens a new definition editor document using the default definition | |
| 305 | * file name. | |
| 306 | */ | |
| 307 | public void newDefinitionEditor() { | |
| 308 | open( DEFINITION_DEFAULT ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 313 | * that they save themselves. | |
| 314 | */ | |
| 315 | public void saveAll() { | |
| 316 | mTabPanes.forEach( | |
| 317 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 318 | final var node = tab.getContent(); | |
| 319 | if( node instanceof TextEditor ) { | |
| 320 | save( ((TextEditor) node) ); | |
| 321 | } | |
| 322 | } ) | |
| 323 | ); | |
| 324 | } | |
| 325 | ||
| 326 | /** | |
| 327 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 328 | * checking if modified first because if the user swaps external media from | |
| 329 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 330 | * the user: save always re-saves. Also, it's less code. | |
| 331 | */ | |
| 332 | public void save() { | |
| 333 | save( getActiveTextEditor() ); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Saves the active {@link TextEditor} under a new name. | |
| 338 | * | |
| 339 | * @param file The new active editor {@link File} reference. | |
| 340 | */ | |
| 341 | public void saveAs( final File file ) { | |
| 342 | assert file != null; | |
| 343 | final var editor = getActiveTextEditor(); | |
| 344 | final var tab = getTab( editor ); | |
| 345 | ||
| 346 | editor.rename( file ); | |
| 347 | tab.ifPresent( t -> { | |
| 348 | t.setText( editor.getFilename() ); | |
| 349 | t.setTooltip( createTooltip( file ) ); | |
| 350 | } ); | |
| 351 | ||
| 352 | save(); | |
| 353 | } | |
| 354 | ||
| 355 | /** | |
| 356 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 357 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 358 | * | |
| 359 | * @param resource The resource to export. | |
| 360 | */ | |
| 361 | private void save( final TextResource resource ) { | |
| 362 | try { | |
| 363 | resource.save(); | |
| 364 | } catch( final Exception ex ) { | |
| 365 | clue( ex ); | |
| 366 | sNotifier.alert( | |
| 367 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 368 | ); | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 374 | * | |
| 375 | * @return {@code true} when all editors, modified or otherwise, were | |
| 376 | * permitted to close; {@code false} when one or more editors were modified | |
| 377 | * and the user requested no closing. | |
| 378 | */ | |
| 379 | public boolean closeAll() { | |
| 380 | var closable = true; | |
| 381 | ||
| 382 | for( final var entry : mTabPanes.entrySet() ) { | |
| 383 | final var tabPane = entry.getValue(); | |
| 384 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 385 | ||
| 386 | while( tabIterator.hasNext() ) { | |
| 387 | final var tab = tabIterator.next(); | |
| 388 | final var resource = tab.getContent(); | |
| 389 | ||
| 390 | // The definition panes auto-save, so being specific here prevents | |
| 391 | // closing the definitions in the situation where the user wants to | |
| 392 | // continue editing (i.e., possibly save unsaved work). | |
| 393 | if( !(resource instanceof TextEditor) ) { | |
| 394 | continue; | |
| 395 | } | |
| 396 | ||
| 397 | if( canClose( (TextEditor) resource ) ) { | |
| 398 | tabIterator.remove(); | |
| 399 | close( tab ); | |
| 400 | } | |
| 401 | else { | |
| 402 | closable = false; | |
| 403 | } | |
| 404 | } | |
| 405 | } | |
| 406 | ||
| 407 | return closable; | |
| 408 | } | |
| 409 | ||
| 410 | /** | |
| 411 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 412 | * event. | |
| 413 | * | |
| 414 | * @param tab The {@link Tab} that was closed. | |
| 415 | */ | |
| 416 | private void close( final Tab tab ) { | |
| 417 | final var handler = tab.getOnClosed(); | |
| 418 | ||
| 419 | if( handler != null ) { | |
| 420 | handler.handle( new ActionEvent() ); | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 426 | */ | |
| 427 | public void close() { | |
| 428 | final var editor = getActiveTextEditor(); | |
| 429 | if( canClose( editor ) ) { | |
| 430 | close( editor ); | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | /** | |
| 435 | * Closes the given {@link TextResource}. This must not be called from within | |
| 436 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 437 | * concurrent modification exception be thrown. | |
| 438 | * | |
| 439 | * @param resource The {@link TextResource} to close, without confirming with | |
| 440 | * the user. | |
| 441 | */ | |
| 442 | private void close( final TextResource resource ) { | |
| 443 | getTab( resource ).ifPresent( | |
| 444 | ( tab ) -> { | |
| 445 | tab.getTabPane().getTabs().remove( tab ); | |
| 446 | close( tab ); | |
| 447 | } | |
| 448 | ); | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * Answers whether the given {@link TextResource} may be closed. | |
| 453 | * | |
| 454 | * @param editor The {@link TextResource} to try closing. | |
| 455 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 456 | * the user has requested to keep the editor open. | |
| 457 | */ | |
| 458 | private boolean canClose( final TextResource editor ) { | |
| 459 | final var editorTab = getTab( editor ); | |
| 460 | final var canClose = new AtomicBoolean( true ); | |
| 461 | ||
| 462 | if( editor.isModified() ) { | |
| 463 | final var filename = new StringBuilder(); | |
| 464 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 465 | ||
| 466 | final var message = sNotifier.createNotification( | |
| 467 | Messages.get( "Alert.file.close.title" ), | |
| 468 | Messages.get( "Alert.file.close.text" ), | |
| 469 | filename.toString() | |
| 470 | ); | |
| 471 | ||
| 472 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 473 | ||
| 474 | dialog.showAndWait().ifPresent( | |
| 475 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 476 | ); | |
| 477 | } | |
| 478 | ||
| 479 | return canClose.get(); | |
| 480 | } | |
| 481 | ||
| 482 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 483 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 484 | ||
| 485 | editor.addListener( ( c, o, n ) -> { | |
| 486 | if( n != null ) { | |
| 487 | mHtmlPreview.setBaseUri( n.getPath() ); | |
| 488 | process( n ); | |
| 489 | } | |
| 490 | } ); | |
| 491 | ||
| 492 | return editor; | |
| 493 | } | |
| 494 | ||
| 495 | /** | |
| 496 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 497 | */ | |
| 498 | public void viewPreview() { | |
| 499 | viewTab( mHtmlPreview, TEXT_HTML, "HTML" ); | |
| 500 | } | |
| 501 | ||
| 502 | /** | |
| 503 | * Adds the document outline tab to its own, singular tab pane. | |
| 504 | */ | |
| 505 | public void viewOutline() { | |
| 506 | viewTab( mDocumentOutline, APP_DOCUMENT_OUTLINE, "Outline" ); | |
| 507 | } | |
| 508 | ||
| 509 | private void viewTab( | |
| 510 | final Node node, final MediaType mediaType, final String name ) { | |
| 511 | final var tabPane = obtainTabPane( mediaType ); | |
| 512 | ||
| 513 | for( final var tab : tabPane.getTabs() ) { | |
| 514 | if( tab.getContent() == node ) { | |
| 515 | return; | |
| 516 | } | |
| 517 | } | |
| 518 | ||
| 519 | tabPane.getTabs().add( createTab( name, node ) ); | |
| 520 | addTabPane( tabPane ); | |
| 521 | } | |
| 522 | ||
| 523 | public void viewRefresh() { | |
| 524 | mHtmlPreview.refresh(); | |
| 525 | } | |
| 526 | ||
| 527 | /** | |
| 528 | * Returns the tab that contains the given {@link TextEditor}. | |
| 529 | * | |
| 530 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 531 | * @return The first tab having content that matches the given tab. | |
| 532 | */ | |
| 533 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 534 | return mTabPanes.values() | |
| 535 | .stream() | |
| 536 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 537 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 538 | .findFirst(); | |
| 539 | } | |
| 540 | ||
| 541 | /** | |
| 542 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 543 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 544 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 545 | * text editor is refreshed. | |
| 546 | * | |
| 547 | * @param editor Text editor to update with the revised resolved map. | |
| 548 | * @return A newly configured property that represents the active | |
| 549 | * {@link DefinitionEditor}, never null. | |
| 550 | */ | |
| 551 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 552 | final ObjectProperty<TextEditor> editor ) { | |
| 553 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 554 | definitions.addListener( ( c, o, n ) -> { | |
| 555 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 556 | process( editor.get() ); | |
| 557 | } ); | |
| 558 | ||
| 559 | return definitions; | |
| 560 | } | |
| 561 | ||
| 562 | private Tab createTab( final String filename, final Node node ) { | |
| 563 | return new DetachableTab( filename, node ); | |
| 564 | } | |
| 565 | ||
| 566 | private Tab createTab( final File file ) { | |
| 567 | final var r = createTextResource( file ); | |
| 568 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 569 | ||
| 570 | r.modifiedProperty().addListener( | |
| 571 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 572 | ); | |
| 573 | ||
| 574 | // This is called when either the tab is closed by the user clicking on | |
| 575 | // the tab's close icon or when closing (all) from the file menu. | |
| 576 | tab.setOnClosed( | |
| 577 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 578 | ); | |
| 579 | ||
| 580 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 581 | if( nPane != null ) { | |
| 582 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 583 | if( n != null && n ) { | |
| 584 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 585 | final var node = selected.getContent(); | |
| 586 | node.requestFocus(); | |
| 587 | } | |
| 588 | } ); | |
| 589 | } | |
| 590 | } ); | |
| 591 | ||
| 592 | return tab; | |
| 593 | } | |
| 594 | ||
| 595 | /** | |
| 596 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 597 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 598 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 599 | * be replaced by such a class. | |
| 600 | * <p> | |
| 601 | * When binning the files, this makes sure that at least one file exists | |
| 602 | * for every type. If the user has opted to close a particular type (such | |
| 603 | * as the definition pane), the view will suppressed elsewhere. | |
| 604 | * </p> | |
| 605 | * <p> | |
| 606 | * The order that the binned files are returned will be reflected in the | |
| 607 | * order that the corresponding panes are rendered in the UI. | |
| 608 | * </p> | |
| 609 | * | |
| 610 | * @param paths The file paths to bin according to their type. | |
| 611 | * @return An in-order list of files, first by structured definition files, | |
| 612 | * then by plain text documents. | |
| 613 | */ | |
| 614 | private List<File> bin( final SetProperty<String> paths ) { | |
| 615 | // Treat all files destined for the text editor as plain text documents | |
| 616 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 617 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 618 | final Function<MediaType, MediaType> bin = | |
| 619 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 620 | ||
| 621 | // Create two groups: YAML files and plain text files. | |
| 622 | final var bins = paths | |
| 623 | .stream() | |
| 624 | .collect( | |
| 625 | groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) ) | |
| 626 | ); | |
| 627 | ||
| 628 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 629 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 630 | ||
| 631 | final var result = new ArrayList<File>( paths.size() ); | |
| 632 | ||
| 633 | // Ensure that the same types are listed together (keep insertion order). | |
| 634 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 635 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 636 | ); | |
| 637 | ||
| 638 | return result; | |
| 639 | } | |
| 640 | ||
| 641 | /** | |
| 642 | * Uses the given {@link TextDefinition} instance to update the | |
| 643 | * {@link #mResolvedMap}. | |
| 644 | * | |
| 645 | * @param editor A non-null, possibly empty definition editor. | |
| 646 | */ | |
| 647 | private void resolve( final TextDefinition editor ) { | |
| 648 | assert editor != null; | |
| 649 | ||
| 650 | final var tokens = createDefinitionTokens(); | |
| 651 | final var operator = new YamlSigilOperator( tokens ); | |
| 652 | final var map = new HashMap<String, String>(); | |
| 653 | ||
| 654 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 655 | ||
| 656 | mResolvedMap.clear(); | |
| 657 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Force the active editor to update, which will cause the processor | |
| 662 | * to re-evaluate the interpolated definition map thereby updating the | |
| 663 | * preview pane. | |
| 664 | * | |
| 665 | * @param editor Contains the source document to update in the preview pane. | |
| 666 | */ | |
| 667 | private void process( final TextEditor editor ) { | |
| 668 | // Ensure that these are run from within the Swing event dispatch thread | |
| 669 | // so that the text editor thread is immediately freed for caret movement. | |
| 670 | // This means that the preview will have a slight delay when catching up | |
| 671 | // to the caret position. | |
| 672 | invokeLater( () -> { | |
| 673 | final var processor = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 674 | processor.apply( editor == null ? "" : editor.getText() ); | |
| 675 | mHtmlPreview.scrollTo( CARET_ID ); | |
| 676 | } ); | |
| 677 | } | |
| 678 | ||
| 679 | /** | |
| 680 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 681 | * events. The tab pane is associated with a given media type so that | |
| 682 | * similar files can be grouped together. | |
| 683 | * | |
| 684 | * @param mediaType The media type to associate with the tab pane. | |
| 685 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 686 | */ | |
| 687 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 688 | return mTabPanes.computeIfAbsent( | |
| 689 | mediaType, ( mt ) -> createTabPane() | |
| 690 | ); | |
| 691 | } | |
| 692 | ||
| 693 | /** | |
| 694 | * Creates an initialized {@link TabPane} instance. | |
| 695 | * | |
| 696 | * @return A new {@link TabPane} with all listeners configured. | |
| 697 | */ | |
| 698 | private TabPane createTabPane() { | |
| 699 | final var tabPane = new DetachableTabPane(); | |
| 700 | ||
| 701 | initStageOwnerFactory( tabPane ); | |
| 702 | initTabListener( tabPane ); | |
| 703 | ||
| 704 | return tabPane; | |
| 705 | } | |
| 706 | ||
| 707 | /** | |
| 708 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 709 | * the stage owner factory must be given its parent window, which will | |
| 710 | * own the child window. The parent window is the {@link MainPane}'s | |
| 711 | * {@link Scene}'s {@link Window} instance. | |
| 712 | * | |
| 713 | * <p> | |
| 714 | * This will derives the new title from the main window title, incrementing | |
| 715 | * the window count to help uniquely identify the child windows. | |
| 716 | * </p> | |
| 717 | * | |
| 718 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 719 | */ | |
| 720 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 721 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 722 | final var title = get( | |
| 723 | "Detach.tab.title", | |
| 724 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 725 | ); | |
| 726 | stage.setTitle( title ); | |
| 727 | ||
| 728 | return getScene().getWindow(); | |
| 729 | } ); | |
| 730 | } | |
| 731 | ||
| 732 | /** | |
| 733 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 734 | * it is added to the given {@link DetachableTabPane} instance. | |
| 735 | * <p> | |
| 736 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 737 | * is initialized to perform synchronized scrolling between the editor and | |
| 738 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 739 | * tabs is given focus. | |
| 740 | * </p> | |
| 741 | * <p> | |
| 742 | * Note that multiple tabs can be added simultaneously. | |
| 743 | * </p> | |
| 744 | * | |
| 745 | * @param tabPane A new {@link TabPane} to configure. | |
| 746 | */ | |
| 747 | private void initTabListener( final TabPane tabPane ) { | |
| 748 | tabPane.getTabs().addListener( | |
| 749 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 750 | while( listener.next() ) { | |
| 751 | if( listener.wasAdded() ) { | |
| 752 | final var tabs = listener.getAddedSubList(); | |
| 753 | ||
| 754 | tabs.forEach( ( tab ) -> { | |
| 755 | final var node = tab.getContent(); | |
| 756 | ||
| 757 | if( node instanceof TextEditor ) { | |
| 758 | initScrollEventListener( tab ); | |
| 759 | } | |
| 760 | } ); | |
| 761 | ||
| 762 | // Select and give focus to the last tab opened. | |
| 763 | final var index = tabs.size() - 1; | |
| 764 | if( index >= 0 ) { | |
| 765 | final var tab = tabs.get( index ); | |
| 766 | tabPane.getSelectionModel().select( tab ); | |
| 767 | tab.getContent().requestFocus(); | |
| 768 | } | |
| 769 | } | |
| 770 | } | |
| 771 | } | |
| 772 | ); | |
| 773 | } | |
| 774 | ||
| 775 | /** | |
| 776 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 777 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 778 | * | |
| 779 | * @param tab The container for an instance of {@link TextEditor}. | |
| 780 | */ | |
| 781 | private void initScrollEventListener( final Tab tab ) { | |
| 782 | final var editor = (TextEditor) tab.getContent(); | |
| 783 | final var scrollPane = editor.getScrollPane(); | |
| 784 | final var scrollBar = mHtmlPreview.getVerticalScrollBar(); | |
| 785 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 786 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 787 | } | |
| 788 | ||
| 789 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 790 | final var items = getItems(); | |
| 791 | if( !items.contains( tabPane ) ) { | |
| 792 | items.add( index, tabPane ); | |
| 793 | } | |
| 794 | } | |
| 795 | ||
| 796 | private void addTabPane( final TabPane tabPane ) { | |
| 797 | addTabPane( getItems().size(), tabPane ); | |
| 798 | } | |
| 799 | ||
| 800 | public ProcessorContext createProcessorContext() { | |
| 801 | return createProcessorContext( NONE ); | |
| 802 | } | |
| 803 | ||
| 804 | public ProcessorContext createProcessorContext( final ExportFormat format ) { | |
| 805 | final var editor = getActiveTextEditor(); | |
| 806 | return createProcessorContext( | |
| 807 | editor.getPath(), editor.getCaret(), format ); | |
| 808 | } | |
| 809 | ||
| 810 | /** | |
| 811 | * @param path Used by {@link ProcessorFactory} to determine | |
| 812 | * {@link Processor} type to create based on file type. | |
| 813 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 814 | * preview document for scrollbar synchronization. | |
| 815 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 816 | * {@link Processor}. | |
| 817 | */ | |
| 818 | private ProcessorContext createProcessorContext( | |
| 819 | final Path path, final Caret caret, final ExportFormat format ) { | |
| 820 | return new ProcessorContext( | |
| 821 | mHtmlPreview, mResolvedMap, path, caret, format, mWorkspace | |
| 116 | private final HtmlPreview mPreview; | |
| 117 | ||
| 118 | /** | |
| 119 | * Provides an interactive document outline. | |
| 120 | */ | |
| 121 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 122 | ||
| 123 | /** | |
| 124 | * Changing the active editor fires the value changed event. This allows | |
| 125 | * refreshes to happen when external definitions are modified and need to | |
| 126 | * trigger the processing chain. | |
| 127 | */ | |
| 128 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 129 | createActiveTextEditor(); | |
| 130 | ||
| 131 | /** | |
| 132 | * Changing the active definition editor fires the value changed event. This | |
| 133 | * allows refreshes to happen when external definitions are modified and need | |
| 134 | * to trigger the processing chain. | |
| 135 | */ | |
| 136 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 137 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 138 | ||
| 139 | /** | |
| 140 | * Tracks the number of detached tab panels opened into their own windows, | |
| 141 | * which allows unique identification of subordinate windows by their title. | |
| 142 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 143 | */ | |
| 144 | private byte mWindowCount; | |
| 145 | ||
| 146 | /** | |
| 147 | * Called when the definition data is changed. | |
| 148 | */ | |
| 149 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 150 | event -> { | |
| 151 | final var editor = mActiveDefinitionEditor.get(); | |
| 152 | ||
| 153 | resolve( editor ); | |
| 154 | process( getActiveTextEditor() ); | |
| 155 | save( editor ); | |
| 156 | }; | |
| 157 | ||
| 158 | /** | |
| 159 | * Adds all content panels to the main user interface. This will load the | |
| 160 | * configuration settings from the workspace to reproduce the settings from | |
| 161 | * a previous session. | |
| 162 | */ | |
| 163 | public MainPane( final Workspace workspace ) { | |
| 164 | mWorkspace = workspace; | |
| 165 | mPreview = new HtmlPreview( workspace ); | |
| 166 | ||
| 167 | open( bin( getRecentFiles() ) ); | |
| 168 | viewPreview(); | |
| 169 | setDividerPositions( calculateDividerPositions() ); | |
| 170 | ||
| 171 | // Once the main scene's window regains focus, update the active definition | |
| 172 | // editor to the currently selected tab. | |
| 173 | runLater( | |
| 174 | () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 175 | // Order matters here. We want to close all the tabs to ensure each | |
| 176 | // is saved, but after they are closed, the workspace should still | |
| 177 | // retain the list of files that were open. If this line came after | |
| 178 | // closing, then restarting the application would list no files. | |
| 179 | mWorkspace.save(); | |
| 180 | ||
| 181 | if( closeAll() ) { | |
| 182 | Platform.exit(); | |
| 183 | System.exit( 0 ); | |
| 184 | } | |
| 185 | else { | |
| 186 | event.consume(); | |
| 187 | } | |
| 188 | } ) | |
| 189 | ); | |
| 190 | ||
| 191 | register( this ); | |
| 192 | } | |
| 193 | ||
| 194 | @Subscribe | |
| 195 | public void handle( final TextEditorFocusEvent event ) { | |
| 196 | mActiveTextEditor.set( event.get() ); | |
| 197 | } | |
| 198 | ||
| 199 | @Subscribe | |
| 200 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 201 | mActiveDefinitionEditor.set( event.get() ); | |
| 202 | } | |
| 203 | ||
| 204 | /** | |
| 205 | * Typically called when a file name is clicked in the {@link HtmlPanel}. | |
| 206 | * | |
| 207 | * @param event The event to process, must contain a valid file reference. | |
| 208 | */ | |
| 209 | @Subscribe | |
| 210 | public void handle( final FileOpenEvent event ) { | |
| 211 | final File eventFile; | |
| 212 | final var eventUri = event.getUri(); | |
| 213 | ||
| 214 | if( eventUri.isAbsolute() ) { | |
| 215 | eventFile = new File( eventUri.getPath() ); | |
| 216 | } | |
| 217 | else { | |
| 218 | final var activeFile = getActiveTextEditor().getFile(); | |
| 219 | final var parent = activeFile.getParentFile(); | |
| 220 | ||
| 221 | if( parent == null ) { | |
| 222 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 223 | return; | |
| 224 | } | |
| 225 | else { | |
| 226 | final var parentPath = parent.getAbsolutePath(); | |
| 227 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | runLater( () -> open( eventFile ) ); | |
| 232 | } | |
| 233 | ||
| 234 | @Subscribe | |
| 235 | public void handle( final CaretNavigationEvent event ) { | |
| 236 | runLater( () -> { | |
| 237 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 238 | textArea.moveTo( event.getOffset() ); | |
| 239 | textArea.requestFollowCaret(); | |
| 240 | textArea.requestFocus(); | |
| 241 | } ); | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 246 | */ | |
| 247 | private double[] calculateDividerPositions() { | |
| 248 | final var ratio = 100f / getItems().size() / 100; | |
| 249 | final var positions = getDividerPositions(); | |
| 250 | ||
| 251 | for( int i = 0; i < positions.length; i++ ) { | |
| 252 | positions[ i ] = ratio * i; | |
| 253 | } | |
| 254 | ||
| 255 | return positions; | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Opens all the files into the application, provided the paths are unique. | |
| 260 | * This may only be called for any type of files that a user can edit | |
| 261 | * (i.e., update and persist), such as definitions and text files. | |
| 262 | * | |
| 263 | * @param files The list of files to open. | |
| 264 | */ | |
| 265 | public void open( final List<File> files ) { | |
| 266 | files.forEach( this::open ); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * This opens the given file. Since the preview pane is not a file that | |
| 271 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 272 | * | |
| 273 | * @param file The file to open. | |
| 274 | */ | |
| 275 | private void open( final File file ) { | |
| 276 | final var tab = createTab( file ); | |
| 277 | final var node = tab.getContent(); | |
| 278 | final var mediaType = MediaType.valueFrom( file ); | |
| 279 | final var tabPane = obtainTabPane( mediaType ); | |
| 280 | ||
| 281 | tab.setTooltip( createTooltip( file ) ); | |
| 282 | tabPane.setFocusTraversable( false ); | |
| 283 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 284 | tabPane.getTabs().add( tab ); | |
| 285 | ||
| 286 | // Attach the tab scene factory for new tab panes. | |
| 287 | if( !getItems().contains( tabPane ) ) { | |
| 288 | addTabPane( | |
| 289 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 293 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 294 | } | |
| 295 | ||
| 296 | /** | |
| 297 | * Opens a new text editor document using the default document file name. | |
| 298 | */ | |
| 299 | public void newTextEditor() { | |
| 300 | open( DOCUMENT_DEFAULT ); | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Opens a new definition editor document using the default definition | |
| 305 | * file name. | |
| 306 | */ | |
| 307 | public void newDefinitionEditor() { | |
| 308 | open( DEFINITION_DEFAULT ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 313 | * that they save themselves. | |
| 314 | */ | |
| 315 | public void saveAll() { | |
| 316 | mTabPanes.forEach( | |
| 317 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 318 | final var node = tab.getContent(); | |
| 319 | if( node instanceof TextEditor ) { | |
| 320 | save( ((TextEditor) node) ); | |
| 321 | } | |
| 322 | } ) | |
| 323 | ); | |
| 324 | } | |
| 325 | ||
| 326 | /** | |
| 327 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 328 | * checking if modified first because if the user swaps external media from | |
| 329 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 330 | * the user: save always re-saves. Also, it's less code. | |
| 331 | */ | |
| 332 | public void save() { | |
| 333 | save( getActiveTextEditor() ); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Saves the active {@link TextEditor} under a new name. | |
| 338 | * | |
| 339 | * @param file The new active editor {@link File} reference. | |
| 340 | */ | |
| 341 | public void saveAs( final File file ) { | |
| 342 | assert file != null; | |
| 343 | final var editor = getActiveTextEditor(); | |
| 344 | final var tab = getTab( editor ); | |
| 345 | ||
| 346 | editor.rename( file ); | |
| 347 | tab.ifPresent( t -> { | |
| 348 | t.setText( editor.getFilename() ); | |
| 349 | t.setTooltip( createTooltip( file ) ); | |
| 350 | } ); | |
| 351 | ||
| 352 | save(); | |
| 353 | } | |
| 354 | ||
| 355 | /** | |
| 356 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 357 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 358 | * | |
| 359 | * @param resource The resource to export. | |
| 360 | */ | |
| 361 | private void save( final TextResource resource ) { | |
| 362 | try { | |
| 363 | resource.save(); | |
| 364 | } catch( final Exception ex ) { | |
| 365 | clue( ex ); | |
| 366 | sNotifier.alert( | |
| 367 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 368 | ); | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 374 | * | |
| 375 | * @return {@code true} when all editors, modified or otherwise, were | |
| 376 | * permitted to close; {@code false} when one or more editors were modified | |
| 377 | * and the user requested no closing. | |
| 378 | */ | |
| 379 | public boolean closeAll() { | |
| 380 | var closable = true; | |
| 381 | ||
| 382 | for( final var entry : mTabPanes.entrySet() ) { | |
| 383 | final var tabPane = entry.getValue(); | |
| 384 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 385 | ||
| 386 | while( tabIterator.hasNext() ) { | |
| 387 | final var tab = tabIterator.next(); | |
| 388 | final var resource = tab.getContent(); | |
| 389 | ||
| 390 | // The definition panes auto-save, so being specific here prevents | |
| 391 | // closing the definitions in the situation where the user wants to | |
| 392 | // continue editing (i.e., possibly save unsaved work). | |
| 393 | if( !(resource instanceof TextEditor) ) { | |
| 394 | continue; | |
| 395 | } | |
| 396 | ||
| 397 | if( canClose( (TextEditor) resource ) ) { | |
| 398 | tabIterator.remove(); | |
| 399 | close( tab ); | |
| 400 | } | |
| 401 | else { | |
| 402 | closable = false; | |
| 403 | } | |
| 404 | } | |
| 405 | } | |
| 406 | ||
| 407 | return closable; | |
| 408 | } | |
| 409 | ||
| 410 | /** | |
| 411 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 412 | * event. | |
| 413 | * | |
| 414 | * @param tab The {@link Tab} that was closed. | |
| 415 | */ | |
| 416 | private void close( final Tab tab ) { | |
| 417 | final var handler = tab.getOnClosed(); | |
| 418 | ||
| 419 | if( handler != null ) { | |
| 420 | handler.handle( new ActionEvent() ); | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 426 | */ | |
| 427 | public void close() { | |
| 428 | final var editor = getActiveTextEditor(); | |
| 429 | if( canClose( editor ) ) { | |
| 430 | close( editor ); | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | /** | |
| 435 | * Closes the given {@link TextResource}. This must not be called from within | |
| 436 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 437 | * concurrent modification exception be thrown. | |
| 438 | * | |
| 439 | * @param resource The {@link TextResource} to close, without confirming with | |
| 440 | * the user. | |
| 441 | */ | |
| 442 | private void close( final TextResource resource ) { | |
| 443 | getTab( resource ).ifPresent( | |
| 444 | ( tab ) -> { | |
| 445 | tab.getTabPane().getTabs().remove( tab ); | |
| 446 | close( tab ); | |
| 447 | } | |
| 448 | ); | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * Answers whether the given {@link TextResource} may be closed. | |
| 453 | * | |
| 454 | * @param editor The {@link TextResource} to try closing. | |
| 455 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 456 | * the user has requested to keep the editor open. | |
| 457 | */ | |
| 458 | private boolean canClose( final TextResource editor ) { | |
| 459 | final var editorTab = getTab( editor ); | |
| 460 | final var canClose = new AtomicBoolean( true ); | |
| 461 | ||
| 462 | if( editor.isModified() ) { | |
| 463 | final var filename = new StringBuilder(); | |
| 464 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 465 | ||
| 466 | final var message = sNotifier.createNotification( | |
| 467 | Messages.get( "Alert.file.close.title" ), | |
| 468 | Messages.get( "Alert.file.close.text" ), | |
| 469 | filename.toString() | |
| 470 | ); | |
| 471 | ||
| 472 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 473 | ||
| 474 | dialog.showAndWait().ifPresent( | |
| 475 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 476 | ); | |
| 477 | } | |
| 478 | ||
| 479 | return canClose.get(); | |
| 480 | } | |
| 481 | ||
| 482 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 483 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 484 | ||
| 485 | editor.addListener( ( c, o, n ) -> { | |
| 486 | if( n != null ) { | |
| 487 | mPreview.setBaseUri( n.getPath() ); | |
| 488 | process( n ); | |
| 489 | } | |
| 490 | } ); | |
| 491 | ||
| 492 | return editor; | |
| 493 | } | |
| 494 | ||
| 495 | /** | |
| 496 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 497 | */ | |
| 498 | public void viewPreview() { | |
| 499 | viewTab( mPreview, TEXT_HTML, get( "Pane.preview.title" ) ); | |
| 500 | } | |
| 501 | ||
| 502 | /** | |
| 503 | * Adds the document outline tab to its own, singular tab pane. | |
| 504 | */ | |
| 505 | public void viewOutline() { | |
| 506 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, get( "Pane.outline.title" ) ); | |
| 507 | } | |
| 508 | ||
| 509 | private void viewTab( | |
| 510 | final Node node, final MediaType mediaType, final String name ) { | |
| 511 | final var tabPane = obtainTabPane( mediaType ); | |
| 512 | ||
| 513 | for( final var tab : tabPane.getTabs() ) { | |
| 514 | if( tab.getContent() == node ) { | |
| 515 | return; | |
| 516 | } | |
| 517 | } | |
| 518 | ||
| 519 | tabPane.getTabs().add( createTab( name, node ) ); | |
| 520 | addTabPane( tabPane ); | |
| 521 | } | |
| 522 | ||
| 523 | public void viewRefresh() { | |
| 524 | mPreview.refresh(); | |
| 525 | } | |
| 526 | ||
| 527 | /** | |
| 528 | * Returns the tab that contains the given {@link TextEditor}. | |
| 529 | * | |
| 530 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 531 | * @return The first tab having content that matches the given tab. | |
| 532 | */ | |
| 533 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 534 | return mTabPanes.values() | |
| 535 | .stream() | |
| 536 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 537 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 538 | .findFirst(); | |
| 539 | } | |
| 540 | ||
| 541 | /** | |
| 542 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 543 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 544 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 545 | * text editor is refreshed. | |
| 546 | * | |
| 547 | * @param editor Text editor to update with the revised resolved map. | |
| 548 | * @return A newly configured property that represents the active | |
| 549 | * {@link DefinitionEditor}, never null. | |
| 550 | */ | |
| 551 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 552 | final ObjectProperty<TextEditor> editor ) { | |
| 553 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 554 | definitions.addListener( ( c, o, n ) -> { | |
| 555 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 556 | process( editor.get() ); | |
| 557 | } ); | |
| 558 | ||
| 559 | return definitions; | |
| 560 | } | |
| 561 | ||
| 562 | private Tab createTab( final String filename, final Node node ) { | |
| 563 | return new DetachableTab( filename, node ); | |
| 564 | } | |
| 565 | ||
| 566 | private Tab createTab( final File file ) { | |
| 567 | final var r = createTextResource( file ); | |
| 568 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 569 | ||
| 570 | r.modifiedProperty().addListener( | |
| 571 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 572 | ); | |
| 573 | ||
| 574 | // This is called when either the tab is closed by the user clicking on | |
| 575 | // the tab's close icon or when closing (all) from the file menu. | |
| 576 | tab.setOnClosed( | |
| 577 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 578 | ); | |
| 579 | ||
| 580 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 581 | if( nPane != null ) { | |
| 582 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 583 | if( n != null && n ) { | |
| 584 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 585 | final var node = selected.getContent(); | |
| 586 | node.requestFocus(); | |
| 587 | } | |
| 588 | } ); | |
| 589 | } | |
| 590 | } ); | |
| 591 | ||
| 592 | return tab; | |
| 593 | } | |
| 594 | ||
| 595 | /** | |
| 596 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 597 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 598 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 599 | * be replaced by such a class. | |
| 600 | * <p> | |
| 601 | * When binning the files, this makes sure that at least one file exists | |
| 602 | * for every type. If the user has opted to close a particular type (such | |
| 603 | * as the definition pane), the view will suppressed elsewhere. | |
| 604 | * </p> | |
| 605 | * <p> | |
| 606 | * The order that the binned files are returned will be reflected in the | |
| 607 | * order that the corresponding panes are rendered in the UI. | |
| 608 | * </p> | |
| 609 | * | |
| 610 | * @param paths The file paths to bin according to their type. | |
| 611 | * @return An in-order list of files, first by structured definition files, | |
| 612 | * then by plain text documents. | |
| 613 | */ | |
| 614 | private List<File> bin( final SetProperty<String> paths ) { | |
| 615 | // Treat all files destined for the text editor as plain text documents | |
| 616 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 617 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 618 | final Function<MediaType, MediaType> bin = | |
| 619 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 620 | ||
| 621 | // Create two groups: YAML files and plain text files. | |
| 622 | final var bins = paths | |
| 623 | .stream() | |
| 624 | .collect( | |
| 625 | groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) ) | |
| 626 | ); | |
| 627 | ||
| 628 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 629 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 630 | ||
| 631 | final var result = new ArrayList<File>( paths.size() ); | |
| 632 | ||
| 633 | // Ensure that the same types are listed together (keep insertion order). | |
| 634 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 635 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 636 | ); | |
| 637 | ||
| 638 | return result; | |
| 639 | } | |
| 640 | ||
| 641 | /** | |
| 642 | * Uses the given {@link TextDefinition} instance to update the | |
| 643 | * {@link #mResolvedMap}. | |
| 644 | * | |
| 645 | * @param editor A non-null, possibly empty definition editor. | |
| 646 | */ | |
| 647 | private void resolve( final TextDefinition editor ) { | |
| 648 | assert editor != null; | |
| 649 | ||
| 650 | final var tokens = createDefinitionTokens(); | |
| 651 | final var operator = new YamlSigilOperator( tokens ); | |
| 652 | final var map = new HashMap<String, String>(); | |
| 653 | ||
| 654 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 655 | ||
| 656 | mResolvedMap.clear(); | |
| 657 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Force the active editor to update, which will cause the processor | |
| 662 | * to re-evaluate the interpolated definition map thereby updating the | |
| 663 | * preview pane. | |
| 664 | * | |
| 665 | * @param editor Contains the source document to update in the preview pane. | |
| 666 | */ | |
| 667 | private void process( final TextEditor editor ) { | |
| 668 | // Ensure that these are run from within the Swing event dispatch thread | |
| 669 | // so that the text editor thread is immediately freed for caret movement. | |
| 670 | // This means that the preview will have a slight delay when catching up | |
| 671 | // to the caret position. | |
| 672 | invokeLater( () -> { | |
| 673 | final var processor = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 674 | processor.apply( editor == null ? "" : editor.getText() ); | |
| 675 | mPreview.scrollTo( CARET_ID ); | |
| 676 | } ); | |
| 677 | } | |
| 678 | ||
| 679 | /** | |
| 680 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 681 | * events. The tab pane is associated with a given media type so that | |
| 682 | * similar files can be grouped together. | |
| 683 | * | |
| 684 | * @param mediaType The media type to associate with the tab pane. | |
| 685 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 686 | */ | |
| 687 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 688 | return mTabPanes.computeIfAbsent( | |
| 689 | mediaType, ( mt ) -> createTabPane() | |
| 690 | ); | |
| 691 | } | |
| 692 | ||
| 693 | /** | |
| 694 | * Creates an initialized {@link TabPane} instance. | |
| 695 | * | |
| 696 | * @return A new {@link TabPane} with all listeners configured. | |
| 697 | */ | |
| 698 | private TabPane createTabPane() { | |
| 699 | final var tabPane = new DetachableTabPane(); | |
| 700 | ||
| 701 | initStageOwnerFactory( tabPane ); | |
| 702 | initTabListener( tabPane ); | |
| 703 | ||
| 704 | return tabPane; | |
| 705 | } | |
| 706 | ||
| 707 | /** | |
| 708 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 709 | * the stage owner factory must be given its parent window, which will | |
| 710 | * own the child window. The parent window is the {@link MainPane}'s | |
| 711 | * {@link Scene}'s {@link Window} instance. | |
| 712 | * | |
| 713 | * <p> | |
| 714 | * This will derives the new title from the main window title, incrementing | |
| 715 | * the window count to help uniquely identify the child windows. | |
| 716 | * </p> | |
| 717 | * | |
| 718 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 719 | */ | |
| 720 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 721 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 722 | final var title = get( | |
| 723 | "Detach.tab.title", | |
| 724 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 725 | ); | |
| 726 | stage.setTitle( title ); | |
| 727 | ||
| 728 | return getScene().getWindow(); | |
| 729 | } ); | |
| 730 | } | |
| 731 | ||
| 732 | /** | |
| 733 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 734 | * it is added to the given {@link DetachableTabPane} instance. | |
| 735 | * <p> | |
| 736 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 737 | * is initialized to perform synchronized scrolling between the editor and | |
| 738 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 739 | * tabs is given focus. | |
| 740 | * </p> | |
| 741 | * <p> | |
| 742 | * Note that multiple tabs can be added simultaneously. | |
| 743 | * </p> | |
| 744 | * | |
| 745 | * @param tabPane A new {@link TabPane} to configure. | |
| 746 | */ | |
| 747 | private void initTabListener( final TabPane tabPane ) { | |
| 748 | tabPane.getTabs().addListener( | |
| 749 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 750 | while( listener.next() ) { | |
| 751 | if( listener.wasAdded() ) { | |
| 752 | final var tabs = listener.getAddedSubList(); | |
| 753 | ||
| 754 | tabs.forEach( ( tab ) -> { | |
| 755 | final var node = tab.getContent(); | |
| 756 | ||
| 757 | if( node instanceof TextEditor ) { | |
| 758 | initScrollEventListener( tab ); | |
| 759 | } | |
| 760 | } ); | |
| 761 | ||
| 762 | // Select and give focus to the last tab opened. | |
| 763 | final var index = tabs.size() - 1; | |
| 764 | if( index >= 0 ) { | |
| 765 | final var tab = tabs.get( index ); | |
| 766 | tabPane.getSelectionModel().select( tab ); | |
| 767 | tab.getContent().requestFocus(); | |
| 768 | } | |
| 769 | } | |
| 770 | } | |
| 771 | } | |
| 772 | ); | |
| 773 | } | |
| 774 | ||
| 775 | /** | |
| 776 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 777 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 778 | * | |
| 779 | * @param tab The container for an instance of {@link TextEditor}. | |
| 780 | */ | |
| 781 | private void initScrollEventListener( final Tab tab ) { | |
| 782 | final var editor = (TextEditor) tab.getContent(); | |
| 783 | final var scrollPane = editor.getScrollPane(); | |
| 784 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 785 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 786 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 787 | } | |
| 788 | ||
| 789 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 790 | final var items = getItems(); | |
| 791 | if( !items.contains( tabPane ) ) { | |
| 792 | items.add( index, tabPane ); | |
| 793 | } | |
| 794 | } | |
| 795 | ||
| 796 | private void addTabPane( final TabPane tabPane ) { | |
| 797 | addTabPane( getItems().size(), tabPane ); | |
| 798 | } | |
| 799 | ||
| 800 | public ProcessorContext createProcessorContext() { | |
| 801 | return createProcessorContext( NONE ); | |
| 802 | } | |
| 803 | ||
| 804 | public ProcessorContext createProcessorContext( final ExportFormat format ) { | |
| 805 | final var editor = getActiveTextEditor(); | |
| 806 | return createProcessorContext( | |
| 807 | editor.getPath(), editor.getCaret(), format ); | |
| 808 | } | |
| 809 | ||
| 810 | /** | |
| 811 | * @param path Used by {@link ProcessorFactory} to determine | |
| 812 | * {@link Processor} type to create based on file type. | |
| 813 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 814 | * preview document for scrollbar synchronization. | |
| 815 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 816 | * {@link Processor}. | |
| 817 | */ | |
| 818 | private ProcessorContext createProcessorContext( | |
| 819 | final Path path, final Caret caret, final ExportFormat format ) { | |
| 820 | return new ProcessorContext( | |
| 821 | mPreview, mResolvedMap, path, caret, format, mWorkspace | |
| 822 | 822 | ); |
| 823 | 823 | } |
| 5 | 5 | import com.keenwrite.editors.TextDefinition; |
| 6 | 6 | import com.keenwrite.sigils.Tokens; |
| 7 | import com.keenwrite.ui.tree.AltTreeView; | |
| 8 | import com.keenwrite.ui.tree.TreeItemConverter; | |
| 7 | 9 | import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon; |
| 8 | 10 | import de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory; |
| ... | ||
| 53 | 55 | */ |
| 54 | 56 | private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem(); |
| 57 | ||
| 58 | /** | |
| 59 | * Converts a tree item value to and from a string.. | |
| 60 | */ | |
| 61 | private final TreeItemConverter mConverter = new TreeItemConverter(); | |
| 55 | 62 | |
| 56 | 63 | /** |
| 57 | 64 | * Contains a view of the definitions. |
| 58 | 65 | */ |
| 59 | private final TreeView<String> mTreeView = new TreeView<>( mTreeRoot ); | |
| 66 | private final TreeView<String> mTreeView = | |
| 67 | new AltTreeView<>( mTreeRoot, mConverter ); | |
| 60 | 68 | |
| 61 | 69 | /** |
| ... | ||
| 112 | 120 | mTreeTransformer = treeTransformer; |
| 113 | 121 | |
| 114 | mTreeView.setEditable( true ); | |
| 115 | mTreeView.setCellFactory( new TreeCellFactory() ); | |
| 122 | //mTreeView.setCellFactory( new TreeCellFactory() ); | |
| 116 | 123 | mTreeView.setContextMenu( createContextMenu() ); |
| 117 | 124 | mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); |
| 118 | mTreeView.setShowRoot( false ); | |
| 119 | 125 | mTreeView.focusedProperty().addListener( this::focused ); |
| 120 | 126 | getSelectionModel().setSelectionMode( MULTIPLE ); |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.editors.definition; | |
| 3 | ||
| 4 | import javafx.scene.Node; | |
| 5 | import javafx.scene.control.TextField; | |
| 6 | import javafx.scene.control.cell.TextFieldTreeCell; | |
| 7 | import javafx.util.StringConverter; | |
| 8 | ||
| 9 | /** | |
| 10 | * Responsible for fixing a focus lost bug in the JavaFX implementation. | |
| 11 | * See https://bugs.openjdk.java.net/browse/JDK-8089514 for details. | |
| 12 | * This implementation borrows from the official documentation on creating | |
| 13 | * tree views: https://docs.oracle.com/javafx/2/ui_controls/tree-view.htm | |
| 14 | */ | |
| 15 | public class FocusAwareTextFieldTreeCell extends TextFieldTreeCell<String> { | |
| 16 | private TextField mTextField; | |
| 17 | ||
| 18 | public FocusAwareTextFieldTreeCell( | |
| 19 | final StringConverter<String> converter ) { | |
| 20 | super( converter ); | |
| 21 | } | |
| 22 | ||
| 23 | @Override | |
| 24 | public void startEdit() { | |
| 25 | super.startEdit(); | |
| 26 | var textField = mTextField; | |
| 27 | ||
| 28 | if( textField == null ) { | |
| 29 | textField = createTextField(); | |
| 30 | } | |
| 31 | else { | |
| 32 | textField.setText( getItem() ); | |
| 33 | } | |
| 34 | ||
| 35 | setText( null ); | |
| 36 | setGraphic( textField ); | |
| 37 | textField.selectAll(); | |
| 38 | textField.requestFocus(); | |
| 39 | ||
| 40 | // When the focus is lost, commit the edit then close the input field. | |
| 41 | // This fixes the unexpected behaviour when user clicks away. | |
| 42 | textField.focusedProperty().addListener( ( l, o, n ) -> { | |
| 43 | if( !n ) { | |
| 44 | commitEdit( mTextField.getText() ); | |
| 45 | } | |
| 46 | } ); | |
| 47 | ||
| 48 | mTextField = textField; | |
| 49 | } | |
| 50 | ||
| 51 | @Override | |
| 52 | public void cancelEdit() { | |
| 53 | super.cancelEdit(); | |
| 54 | setText( getItem() ); | |
| 55 | setGraphic( getTreeItem().getGraphic() ); | |
| 56 | } | |
| 57 | ||
| 58 | @Override | |
| 59 | public void updateItem( String item, boolean empty ) { | |
| 60 | super.updateItem( item, empty ); | |
| 61 | ||
| 62 | String text = null; | |
| 63 | Node graphic = null; | |
| 64 | ||
| 65 | if( !empty ) { | |
| 66 | if( isEditing() ) { | |
| 67 | final var textField = mTextField; | |
| 68 | ||
| 69 | if( textField != null ) { | |
| 70 | textField.setText( getString() ); | |
| 71 | } | |
| 72 | ||
| 73 | graphic = textField; | |
| 74 | } | |
| 75 | else { | |
| 76 | text = getString(); | |
| 77 | graphic = getTreeItem().getGraphic(); | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | setText( text ); | |
| 82 | setGraphic( graphic ); | |
| 83 | } | |
| 84 | ||
| 85 | private TextField createTextField() { | |
| 86 | final var textField = new TextField( getString() ); | |
| 87 | ||
| 88 | textField.setOnKeyReleased( t -> { | |
| 89 | switch( t.getCode() ) { | |
| 90 | case ENTER -> commitEdit( textField.getText() ); | |
| 91 | case ESCAPE -> cancelEdit(); | |
| 92 | } | |
| 93 | } ); | |
| 94 | ||
| 95 | return textField; | |
| 96 | } | |
| 97 | ||
| 98 | private String getString() { | |
| 99 | return getConverter().toString( getItem() ); | |
| 100 | } | |
| 101 | } | |
| 102 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.editors.definition; | |
| 3 | ||
| 4 | import javafx.collections.ObservableList; | |
| 5 | import javafx.scene.control.TreeCell; | |
| 6 | import javafx.scene.control.TreeItem; | |
| 7 | import javafx.scene.control.TreeView; | |
| 8 | import javafx.scene.input.ClipboardContent; | |
| 9 | import javafx.scene.input.DataFormat; | |
| 10 | import javafx.scene.input.DragEvent; | |
| 11 | import javafx.scene.input.MouseEvent; | |
| 12 | import javafx.util.Callback; | |
| 13 | import javafx.util.StringConverter; | |
| 14 | ||
| 15 | import java.util.Objects; | |
| 16 | ||
| 17 | import static com.keenwrite.io.MediaType.APP_JAVA_OBJECT; | |
| 18 | import static javafx.scene.input.TransferMode.MOVE; | |
| 19 | ||
| 20 | /** | |
| 21 | * Responsible for producing {@link TreeCell} instances that can be edited | |
| 22 | * and respond to drag and drop functionality. | |
| 23 | */ | |
| 24 | public final class TreeCellFactory | |
| 25 | implements Callback<TreeView<String>, TreeCell<String>> { | |
| 26 | private static final String STYLE_CLASS_DROP_TARGET = "drop-target"; | |
| 27 | private static final DataFormat JAVA_FORMAT = | |
| 28 | new DataFormat( APP_JAVA_OBJECT.toString() ); | |
| 29 | ||
| 30 | private TreeItem<String> mDraggedTreeItem; | |
| 31 | private TreeCell<String> mTargetCell; | |
| 32 | ||
| 33 | /** | |
| 34 | * Constructs a new {@link TreeCell} manufacturing facility called when | |
| 35 | * a new {@link TreeItem} is added to one of the editor's {@link TreeView}s. | |
| 36 | */ | |
| 37 | public TreeCellFactory() { | |
| 38 | } | |
| 39 | ||
| 40 | @Override | |
| 41 | public TreeCell<String> call( final TreeView<String> treeView ) { | |
| 42 | final var cell = createTreeCell(); | |
| 43 | ||
| 44 | cell.setOnDragDetected( event -> dragDetected( event, cell ) ); | |
| 45 | cell.setOnDragOver( event -> dragOver( event, cell ) ); | |
| 46 | cell.setOnDragDropped( event -> dragDropped( event, cell, treeView ) ); | |
| 47 | cell.setOnDragDone( event -> dragClear() ); | |
| 48 | ||
| 49 | return cell; | |
| 50 | } | |
| 51 | ||
| 52 | private TreeCell<String> createTreeCell() { | |
| 53 | return new FocusAwareTextFieldTreeCell( createStringConverter() ) { | |
| 54 | @Override | |
| 55 | public void commitEdit( final String newValue ) { | |
| 56 | super.commitEdit( newValue ); | |
| 57 | //mEditor.select( getTreeItem() ); | |
| 58 | requestFocus(); | |
| 59 | } | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | private StringConverter<String> createStringConverter() { | |
| 64 | return new StringConverter<>() { | |
| 65 | @Override | |
| 66 | public String toString( final String object ) { | |
| 67 | return sanitize( object ); | |
| 68 | } | |
| 69 | ||
| 70 | @Override | |
| 71 | public String fromString( final String string ) { | |
| 72 | return sanitize( string ); | |
| 73 | } | |
| 74 | ||
| 75 | private String sanitize( final String string ) { | |
| 76 | return string == null ? "" : string; | |
| 77 | } | |
| 78 | }; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Drag start. | |
| 83 | * | |
| 84 | * @param event The drag start {@link MouseEvent}. | |
| 85 | * @param treeCell The cell being dragged. | |
| 86 | */ | |
| 87 | private void dragDetected( | |
| 88 | final MouseEvent event, final TreeCell<String> treeCell ) { | |
| 89 | final var sourceItem = treeCell.getTreeItem(); | |
| 90 | ||
| 91 | // Prevent dragging the root item. | |
| 92 | if( sourceItem != null && sourceItem.getParent() != null ) { | |
| 93 | final var dragboard = treeCell.startDragAndDrop( MOVE ); | |
| 94 | final var clipboard = new ClipboardContent(); | |
| 95 | clipboard.put( JAVA_FORMAT, sourceItem.getValue() ); | |
| 96 | dragboard.setContent( clipboard ); | |
| 97 | dragboard.setDragView( treeCell.snapshot( null, null ) ); | |
| 98 | event.consume(); | |
| 99 | ||
| 100 | mDraggedTreeItem = sourceItem; | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Drag over another {@link TreeCell} instance. | |
| 106 | * | |
| 107 | * @param event The drag over {@link DragEvent}. | |
| 108 | * @param treeCell The cell dragged over. | |
| 109 | * @throws IllegalStateException Drag transfer "move" mode denied. | |
| 110 | */ | |
| 111 | private void dragOver( | |
| 112 | final DragEvent event, final TreeCell<String> treeCell ) { | |
| 113 | if( event.getDragboard().hasContent( JAVA_FORMAT ) ) { | |
| 114 | final var thisItem = treeCell.getTreeItem(); | |
| 115 | ||
| 116 | if( mDraggedTreeItem == null || | |
| 117 | thisItem == null || | |
| 118 | thisItem == mDraggedTreeItem ) { | |
| 119 | return; | |
| 120 | } | |
| 121 | ||
| 122 | // Ignore dragging over the root item. | |
| 123 | if( mDraggedTreeItem.getParent() == null ) { | |
| 124 | dragClear(); | |
| 125 | return; | |
| 126 | } | |
| 127 | ||
| 128 | event.acceptTransferModes( MOVE ); | |
| 129 | ||
| 130 | if( !Objects.equals( mTargetCell, treeCell ) ) { | |
| 131 | dragClear(); | |
| 132 | mTargetCell = treeCell; | |
| 133 | mTargetCell.getStyleClass().add( STYLE_CLASS_DROP_TARGET ); | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Dragged item is dropped | |
| 140 | * | |
| 141 | * @param event The drag dropped {@link DragEvent}. | |
| 142 | * @param treeCell The cell dropped onto. | |
| 143 | */ | |
| 144 | private void dragDropped( final DragEvent event, | |
| 145 | final TreeCell<String> treeCell, | |
| 146 | final TreeView<String> treeView ) { | |
| 147 | if( !event.getDragboard().hasContent( JAVA_FORMAT ) ) { | |
| 148 | return; | |
| 149 | } | |
| 150 | ||
| 151 | final var sourceItem = mDraggedTreeItem; | |
| 152 | final var sourceItemParent = mDraggedTreeItem.getParent(); | |
| 153 | final var targetItem = treeCell.getTreeItem(); | |
| 154 | final var targetItemParent = targetItem.getParent(); | |
| 155 | ||
| 156 | sourceItemParent.getChildren().remove( sourceItem ); | |
| 157 | ||
| 158 | final ObservableList<TreeItem<String>> children; | |
| 159 | final int index; | |
| 160 | ||
| 161 | // Dropping onto a parent node makes the source item the first child. | |
| 162 | if( Objects.equals( sourceItemParent, targetItem ) ) { | |
| 163 | children = targetItem.getChildren(); | |
| 164 | index = 0; | |
| 165 | } | |
| 166 | else if( targetItemParent != null) { | |
| 167 | children = targetItemParent.getChildren(); | |
| 168 | index = children.indexOf( targetItem ) + 1; | |
| 169 | } | |
| 170 | else { | |
| 171 | children = sourceItemParent.getChildren(); | |
| 172 | index = 0; | |
| 173 | } | |
| 174 | ||
| 175 | children.add( index, sourceItem ); | |
| 176 | ||
| 177 | treeView.getSelectionModel().clearSelection(); | |
| 178 | treeView.getSelectionModel().select( sourceItem ); | |
| 179 | ||
| 180 | // TODO: Notify a listener of the old and new tree item position. | |
| 181 | ||
| 182 | event.setDropCompleted( true ); | |
| 183 | } | |
| 184 | ||
| 185 | private void dragClear() { | |
| 186 | final var targetCell = mTargetCell; | |
| 187 | ||
| 188 | if( targetCell != null ) { | |
| 189 | targetCell.getStyleClass().remove( STYLE_CLASS_DROP_TARGET ); | |
| 190 | } | |
| 191 | } | |
| 192 | } | |
| 193 | 1 |
| 2 | 2 | package com.keenwrite.io; |
| 3 | 3 | |
| 4 | import javax.net.ssl.*; | |
| 5 | 4 | import java.net.MalformedURLException; |
| 6 | import java.net.Socket; | |
| 7 | 5 | import java.net.URI; |
| 8 | 6 | import java.net.URL; |
| 9 | 7 | import java.net.http.HttpClient; |
| 10 | 8 | import java.net.http.HttpRequest; |
| 11 | import java.security.cert.X509Certificate; | |
| 12 | 9 | |
| 13 | 10 | import static com.keenwrite.events.StatusEvent.clue; |
| ... | ||
| 23 | 20 | */ |
| 24 | 21 | public final class HttpMediaType { |
| 25 | ||
| 26 | static { | |
| 27 | disableSSLVerification(); | |
| 28 | } | |
| 29 | 22 | |
| 30 | 23 | private static final HttpClient HTTP_CLIENT = HttpClient |
| ... | ||
| 84 | 77 | |
| 85 | 78 | return mediaType[ 0 ]; |
| 86 | } | |
| 87 | ||
| 88 | // Method used for bypassing SSL verification | |
| 89 | private static void disableSSLVerification() { | |
| 90 | ||
| 91 | TrustManager[] trustAllCerts = | |
| 92 | new TrustManager[]{new X509ExtendedTrustManager() { | |
| 93 | @Override | |
| 94 | public void checkClientTrusted( X509Certificate[] chain, | |
| 95 | String authType, | |
| 96 | Socket socket ) { | |
| 97 | ||
| 98 | } | |
| 99 | ||
| 100 | @Override | |
| 101 | public void checkServerTrusted( X509Certificate[] chain, | |
| 102 | String authType, | |
| 103 | Socket socket ) { | |
| 104 | ||
| 105 | } | |
| 106 | ||
| 107 | @Override | |
| 108 | public void checkClientTrusted( X509Certificate[] chain, | |
| 109 | String authType, | |
| 110 | SSLEngine engine ) { | |
| 111 | ||
| 112 | } | |
| 113 | ||
| 114 | @Override | |
| 115 | public void checkServerTrusted( X509Certificate[] chain, | |
| 116 | String authType, | |
| 117 | SSLEngine engine ) { | |
| 118 | ||
| 119 | } | |
| 120 | ||
| 121 | @Override | |
| 122 | public java.security.cert.X509Certificate[] getAcceptedIssuers() { | |
| 123 | return null; | |
| 124 | } | |
| 125 | ||
| 126 | @Override | |
| 127 | public void checkClientTrusted( | |
| 128 | X509Certificate[] certs, String authType ) { | |
| 129 | } | |
| 130 | ||
| 131 | @Override | |
| 132 | public void checkServerTrusted( | |
| 133 | X509Certificate[] certs, String authType ) { | |
| 134 | } | |
| 135 | }}; | |
| 136 | ||
| 137 | try { | |
| 138 | final var context = SSLContext.getInstance( "SSL" ); | |
| 139 | context.init( null, trustAllCerts, new java.security.SecureRandom() ); | |
| 140 | HttpsURLConnection.setDefaultSSLSocketFactory( context.getSocketFactory() ); | |
| 141 | HttpsURLConnection.setDefaultHostnameVerifier( ( hostname, session ) -> true ); | |
| 142 | } catch( final Exception ex ) { | |
| 143 | clue( ex ); | |
| 144 | } | |
| 145 | 79 | } |
| 146 | 80 | } |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 4 | import com.keenwrite.Constants; | |
| 5 | import com.keenwrite.preferences.LocaleProperty; | |
| 6 | import com.keenwrite.preferences.Workspace; | |
| 7 | import javafx.application.Platform; | |
| 8 | import javafx.beans.property.DoubleProperty; | |
| 9 | import javafx.beans.property.StringProperty; | |
| 10 | import javafx.embed.swing.SwingNode; | |
| 11 | import org.xhtmlrenderer.render.Box; | |
| 12 | import org.xhtmlrenderer.swing.SwingReplacedElementFactory; | |
| 13 | ||
| 14 | import javax.swing.*; | |
| 15 | 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.Constants.*; | |
| 21 | import static com.keenwrite.Messages.get; | |
| 22 | import static com.keenwrite.events.StatusEvent.clue; | |
| 23 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 24 | import static java.lang.Math.max; | |
| 25 | import static java.lang.String.format; | |
| 26 | import static java.lang.Thread.sleep; | |
| 27 | import static javafx.application.Platform.runLater; | |
| 28 | import static javafx.scene.CacheHint.SPEED; | |
| 29 | import static javax.swing.SwingUtilities.invokeLater; | |
| 30 | ||
| 31 | /** | |
| 32 | * Responsible for parsing an HTML document. | |
| 33 | */ | |
| 34 | public final class HtmlPreview extends SwingNode { | |
| 35 | ||
| 36 | /** | |
| 37 | * The order is important: Swing factory will replace SVG images with | |
| 38 | * a blank image, which will cause the chained factory to cache the image | |
| 39 | * and exit. Instead, the SVG must execute first to rasterize the content. | |
| 40 | * Consequently, the chained factory must maintain insertion order. | |
| 41 | */ | |
| 42 | private static final ChainedReplacedElementFactory FACTORY | |
| 43 | = new ChainedReplacedElementFactory( | |
| 44 | new SvgReplacedElementFactory(), | |
| 45 | new SwingReplacedElementFactory() | |
| 46 | ); | |
| 47 | ||
| 48 | /** | |
| 49 | * Used to populate the {@link #HTML_HEAD} with stylesheet file references. | |
| 50 | */ | |
| 51 | private static final String HTML_STYLESHEET = | |
| 52 | "<link rel='stylesheet' href='%s'/>"; | |
| 53 | ||
| 54 | /** | |
| 55 | * Render CSS using points (pt) not pixels (px) to reduce the chance of | |
| 56 | * poor rendering. The {@link #head()} method fills out the placeholders. | |
| 57 | * When the user has not set a locale, only one stylesheet is added to | |
| 58 | * the document. | |
| 59 | * <p> | |
| 60 | * Do not use points, only pixels here. | |
| 61 | * </p> | |
| 62 | */ | |
| 63 | private static final String HTML_HEAD = | |
| 64 | """ | |
| 65 | <!doctype html> | |
| 66 | <html lang='%s'><head><title> </title><meta charset='utf-8'/> | |
| 67 | %s%s<style>body{font-family:'%s';font-size: %dpx;}</style> | |
| 68 | <base href='%s'/></head><body> | |
| 69 | """; | |
| 70 | ||
| 71 | private static final String HTML_TAIL = "</body></html>"; | |
| 72 | ||
| 73 | private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW ); | |
| 74 | ||
| 75 | /** | |
| 76 | * The buffer is reused so that previous memory allocations need not repeat. | |
| 77 | */ | |
| 78 | private final StringBuilder mHtmlDocument = new StringBuilder( 65536 ); | |
| 79 | ||
| 80 | private HtmlPanel mView; | |
| 81 | private JScrollPane mScrollPane; | |
| 82 | private String mBaseUriPath = ""; | |
| 83 | ||
| 84 | /** | |
| 85 | * Populates {@link Constants#STYLESHEET_PREVIEW_LOCALE} for stylesheet. | |
| 86 | */ | |
| 87 | private URL mLocaleUrl; | |
| 88 | ||
| 89 | private final Workspace mWorkspace; | |
| 90 | ||
| 91 | /** | |
| 92 | * Creates a new preview pane that can scroll to the caret position within the | |
| 93 | * document. | |
| 94 | * | |
| 95 | * @param workspace Contains locale and font size information. | |
| 96 | */ | |
| 97 | public HtmlPreview( final Workspace workspace ) { | |
| 98 | mWorkspace = workspace; | |
| 99 | mLocaleUrl = toUrl( getLocale() ); | |
| 100 | ||
| 101 | // Attempts to prevent a flash of black un-styled content upon load. | |
| 102 | setStyle( "-fx-background-color: white;" ); | |
| 103 | ||
| 104 | invokeLater( () -> { | |
| 105 | mView = new HtmlPanel(); | |
| 106 | mScrollPane = new JScrollPane( mView ); | |
| 107 | ||
| 108 | // Enabling the cache attempts to prevent black flashes when resizing. | |
| 109 | setCache( true ); | |
| 110 | setCacheHint( SPEED ); | |
| 111 | setContent( mScrollPane ); | |
| 112 | ||
| 113 | final var context = mView.getSharedContext(); | |
| 114 | final var textRenderer = context.getTextRenderer(); | |
| 115 | context.setReplacedElementFactory( FACTORY ); | |
| 116 | textRenderer.setSmoothingThreshold( 0 ); | |
| 117 | ||
| 118 | localeProperty().addListener( ( c, o, n ) -> { | |
| 119 | mLocaleUrl = toUrl( getLocale() ); | |
| 120 | rerender(); | |
| 121 | } ); | |
| 122 | ||
| 123 | fontFamilyProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 124 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 125 | } ); | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Updates the internal HTML source shown in the preview pane. | |
| 130 | * | |
| 131 | * @param html The new HTML document to display. | |
| 132 | */ | |
| 133 | public void render( final String html ) { | |
| 134 | mView.render( decorate( html ), getBaseUri() ); | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * Clears the caches then rerenders the content. | |
| 139 | */ | |
| 140 | public void refresh() { | |
| 141 | FACTORY.clearCache(); | |
| 142 | rerender(); | |
| 143 | } | |
| 144 | ||
| 145 | private void rerender() { | |
| 146 | render( mHtmlDocument.toString() ); | |
| 147 | } | |
| 148 | ||
| 149 | /** | |
| 150 | * Attaches the HTML head prefix and HTML tail suffix to the given HTML | |
| 151 | * string. | |
| 152 | * | |
| 153 | * @param html The HTML to adorn with opening and closing tags. | |
| 154 | * @return A complete HTML document, ready for rendering. | |
| 155 | */ | |
| 156 | private String decorate( final String html ) { | |
| 157 | mHtmlDocument.setLength( 0 ); | |
| 158 | mHtmlDocument.append( head() ); | |
| 159 | mHtmlDocument.append( html ); | |
| 160 | mHtmlDocument.append( tail() ); | |
| 161 | return mHtmlDocument.toString(); | |
| 162 | } | |
| 163 | ||
| 164 | private String head() { | |
| 165 | return format( | |
| 166 | HTML_HEAD, | |
| 167 | getLocale().getLanguage(), | |
| 168 | format( HTML_STYLESHEET, HTML_STYLE_PREVIEW ), | |
| 169 | mLocaleUrl == null ? "" : format( HTML_STYLESHEET, mLocaleUrl ), | |
| 170 | getFontFamily(), | |
| 171 | (int) (getFontSize() * (1 + 1 / 3f)), | |
| 172 | mBaseUriPath | |
| 173 | ); | |
| 174 | } | |
| 175 | ||
| 176 | private String tail() { | |
| 177 | return HTML_TAIL; | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Clears the preview pane by rendering an empty string. | |
| 182 | */ | |
| 183 | public void clear() { | |
| 184 | render( "" ); | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Sets the base URI to the containing directory the file being edited. | |
| 189 | * | |
| 190 | * @param path The path to the file being edited. | |
| 191 | */ | |
| 192 | public void setBaseUri( final Path path ) { | |
| 193 | final var parent = path.getParent(); | |
| 194 | mBaseUriPath = parent == null ? "" : parent.toUri().toString(); | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Scrolls to the closest element matching the given identifier without | |
| 199 | * waiting for the document to be ready. | |
| 200 | * | |
| 201 | * @param id Scroll the preview pane to this unique paragraph identifier. | |
| 202 | */ | |
| 203 | public void scrollTo( final String id ) { | |
| 204 | final Runnable scrollToBox = () -> { | |
| 205 | int iter = 0; | |
| 206 | Box box = null; | |
| 207 | ||
| 208 | while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) { | |
| 209 | try { | |
| 210 | sleep( 10 ); | |
| 211 | } catch( final Exception ex ) { | |
| 212 | clue( ex ); | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 | scrollTo( box ); | |
| 217 | }; | |
| 218 | ||
| 219 | if( Platform.isFxApplicationThread() ) { | |
| 220 | scrollToBox.run(); | |
| 221 | } | |
| 222 | else { | |
| 223 | runLater( scrollToBox ); | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Scrolls to the location specified by the {@link Box} that corresponds | |
| 229 | * to a point somewhere in the preview pane. If there is no caret, then | |
| 230 | * this will not change the scroll position. Changing the scroll position | |
| 231 | * to the top if the {@link Box} instance is {@code null} will result in | |
| 232 | * jumping around a lot and inconsistent synchronization issues. | |
| 233 | * | |
| 234 | * @param box The rectangular region containing the caret, or {@code null} | |
| 235 | * if the HTML does not have a caret. | |
| 236 | */ | |
| 237 | private void scrollTo( final Box box ) { | |
| 238 | if( box != null ) { | |
| 239 | scrollTo( createPoint( box ) ); | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | private void scrollTo( final Point point ) { | |
| 244 | invokeLater( () -> { | |
| 245 | mView.scrollTo( point ); | |
| 246 | getScrollPane().repaint(); | |
| 247 | } ); | |
| 248 | } | |
| 249 | ||
| 250 | /** | |
| 251 | * Creates a {@link Point} to use as a reference for scrolling to the area | |
| 252 | * described by the given {@link Box}. The {@link Box} coordinates are used | |
| 253 | * to populate the {@link Point}'s location, with minor adjustments for | |
| 254 | * vertical centering. | |
| 255 | * | |
| 256 | * @param box The {@link Box} that represents a scrolling anchor reference. | |
| 257 | * @return A coordinate suitable for scrolling to. | |
| 258 | */ | |
| 259 | private Point createPoint( final Box box ) { | |
| 260 | assert box != null; | |
| 261 | ||
| 262 | // Scroll back up by half the height of the scroll bar to keep the typing | |
| 263 | // area within the view port. Otherwise the view port will have jumped too | |
| 264 | // high up and the most recently typed letters won't be visible. | |
| 265 | int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 ); | |
| 266 | int x = box.getAbsX(); | |
| 267 | ||
| 268 | if( !box.getStyle().isInline() ) { | |
| 269 | final var margin = box.getMargin( mView.getLayoutContext() ); | |
| 270 | y += margin.top(); | |
| 271 | x += margin.left(); | |
| 272 | } | |
| 273 | ||
| 274 | return new Point( x, y ); | |
| 275 | } | |
| 276 | ||
| 277 | private String getBaseUri() { | |
| 278 | return mBaseUriPath; | |
| 279 | } | |
| 280 | ||
| 281 | private JScrollPane getScrollPane() { | |
| 282 | return mScrollPane; | |
| 283 | } | |
| 284 | ||
| 285 | public JScrollBar getVerticalScrollBar() { | |
| 286 | return getScrollPane().getVerticalScrollBar(); | |
| 287 | } | |
| 288 | ||
| 289 | private int getVerticalScrollBarHeight() { | |
| 290 | return getVerticalScrollBar().getHeight(); | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen | |
| 295 | * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166 | |
| 296 | * alpha-2 country code or UN M.49 numeric-3 area code. For example, this | |
| 297 | * could return "en-Latn-CA" for Canadian English written in the Latin | |
| 298 | * character set. | |
| 299 | * | |
| 300 | * @return Unique identifier for language and country. | |
| 301 | */ | |
| 302 | private static URL toUrl( final Locale locale ) { | |
| 303 | return toUrl( | |
| 304 | get( | |
| 305 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), | |
| 306 | locale.getLanguage(), | |
| 307 | locale.getScript(), | |
| 308 | locale.getCountry() | |
| 309 | ) | |
| 310 | ); | |
| 311 | } | |
| 312 | ||
| 313 | private static URL toUrl( final String path ) { | |
| 314 | return HtmlPreview.class.getResource( path ); | |
| 315 | } | |
| 316 | ||
| 317 | private Locale getLocale() { | |
| 318 | return localeProperty().toLocale(); | |
| 319 | } | |
| 320 | ||
| 321 | private LocaleProperty localeProperty() { | |
| 322 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 323 | } | |
| 324 | ||
| 325 | private String getFontFamily() { | |
| 326 | return fontFamilyProperty().get(); | |
| 327 | } | |
| 328 | ||
| 329 | private StringProperty fontFamilyProperty() { | |
| 330 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 331 | } | |
| 332 | ||
| 333 | private double getFontSize() { | |
| 334 | return fontSizeProperty().get(); | |
| 335 | } | |
| 336 | ||
| 337 | /** | |
| 338 | * Returns the font size in points. | |
| 4 | import com.keenwrite.preferences.LocaleProperty; | |
| 5 | import com.keenwrite.preferences.Workspace; | |
| 6 | import javafx.application.Platform; | |
| 7 | import javafx.beans.property.DoubleProperty; | |
| 8 | import javafx.beans.property.StringProperty; | |
| 9 | import javafx.embed.swing.SwingNode; | |
| 10 | import org.xhtmlrenderer.render.Box; | |
| 11 | import org.xhtmlrenderer.swing.SwingReplacedElementFactory; | |
| 12 | ||
| 13 | import javax.swing.*; | |
| 14 | import java.awt.*; | |
| 15 | import java.net.URL; | |
| 16 | import java.nio.file.Path; | |
| 17 | import java.util.Locale; | |
| 18 | ||
| 19 | import static com.keenwrite.Constants.*; | |
| 20 | import static com.keenwrite.Messages.get; | |
| 21 | import static com.keenwrite.events.StatusEvent.clue; | |
| 22 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 23 | import static java.lang.Math.max; | |
| 24 | import static java.lang.String.format; | |
| 25 | import static java.lang.Thread.sleep; | |
| 26 | import static javafx.application.Platform.runLater; | |
| 27 | import static javafx.scene.CacheHint.SPEED; | |
| 28 | import static javax.swing.SwingUtilities.invokeLater; | |
| 29 | ||
| 30 | /** | |
| 31 | * Responsible for parsing an HTML document. | |
| 32 | */ | |
| 33 | public final class HtmlPreview extends SwingNode { | |
| 34 | ||
| 35 | /** | |
| 36 | * The order is important: Swing factory will replace SVG images with | |
| 37 | * a blank image, which will cause the chained factory to cache the image | |
| 38 | * and exit. Instead, the SVG must execute first to rasterize the content. | |
| 39 | * Consequently, the chained factory must maintain insertion order. | |
| 40 | */ | |
| 41 | private static final ChainedReplacedElementFactory FACTORY | |
| 42 | = new ChainedReplacedElementFactory( | |
| 43 | new SvgReplacedElementFactory(), | |
| 44 | new SwingReplacedElementFactory() | |
| 45 | ); | |
| 46 | ||
| 47 | /** | |
| 48 | * Used to populate the {@link #HTML_HEAD} with stylesheet file references. | |
| 49 | */ | |
| 50 | private static final String HTML_STYLESHEET = | |
| 51 | "<link rel='stylesheet' href='%s'>"; | |
| 52 | ||
| 53 | private static final String HTML_BASE = | |
| 54 | "<base href='%s'>"; | |
| 55 | ||
| 56 | /** | |
| 57 | * Render CSS using points (pt) not pixels (px) to reduce the chance of | |
| 58 | * poor rendering. The {@link #generateHead()} method fills placeholders. | |
| 59 | * When the user has not set a locale, only one stylesheet is added to | |
| 60 | * the document. In order, the placeholders are as follows: | |
| 61 | * <ol> | |
| 62 | * <li>%s --- language</li> | |
| 63 | * <li>%s --- default stylesheet</li> | |
| 64 | * <li>%s --- language-specific stylesheet</li> | |
| 65 | * <li>%s --- font family</li> | |
| 66 | * <li>%d --- font size (must be pixels, not points due to bug)</li> | |
| 67 | * <li>%s --- base href</li> | |
| 68 | * </p> | |
| 69 | */ | |
| 70 | private static final String HTML_HEAD = | |
| 71 | """ | |
| 72 | <!doctype html> | |
| 73 | <html lang='%s'><head><title> </title><meta charset='utf-8'> | |
| 74 | %s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body> | |
| 75 | """; | |
| 76 | ||
| 77 | private static final String HTML_TAIL = "</body></html>"; | |
| 78 | ||
| 79 | private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW ); | |
| 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 final Workspace mWorkspace; | |
| 92 | ||
| 93 | /** | |
| 94 | * Creates a new preview pane that can scroll to the caret position within the | |
| 95 | * document. | |
| 96 | * | |
| 97 | * @param workspace Contains locale and font size information. | |
| 98 | */ | |
| 99 | public HtmlPreview( final Workspace workspace ) { | |
| 100 | mWorkspace = workspace; | |
| 101 | ||
| 102 | // Attempts to prevent a flash of black un-styled content upon load. | |
| 103 | setStyle( "-fx-background-color: white;" ); | |
| 104 | ||
| 105 | invokeLater( () -> { | |
| 106 | mHead = generateHead(); | |
| 107 | mView = new HtmlPanel(); | |
| 108 | mScrollPane = new JScrollPane( mView ); | |
| 109 | ||
| 110 | // Enabling the cache attempts to prevent black flashes when resizing. | |
| 111 | setCache( true ); | |
| 112 | setCacheHint( SPEED ); | |
| 113 | setContent( mScrollPane ); | |
| 114 | ||
| 115 | final var context = mView.getSharedContext(); | |
| 116 | final var textRenderer = context.getTextRenderer(); | |
| 117 | context.setReplacedElementFactory( FACTORY ); | |
| 118 | textRenderer.setSmoothingThreshold( 0 ); | |
| 119 | ||
| 120 | localeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 121 | fontFamilyProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 122 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 123 | } ); | |
| 124 | } | |
| 125 | ||
| 126 | /** | |
| 127 | * Updates the internal HTML source shown in the preview pane. | |
| 128 | * | |
| 129 | * @param html The new HTML document to display. | |
| 130 | */ | |
| 131 | public void render( final String html ) { | |
| 132 | mView.render( decorate( html ), getBaseUri() ); | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * Clears the caches then rerenders the content. | |
| 137 | */ | |
| 138 | public void refresh() { | |
| 139 | FACTORY.clearCache(); | |
| 140 | rerender(); | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Recomputes the HTML head then renders the document. | |
| 145 | */ | |
| 146 | private void rerender() { | |
| 147 | mHead = generateHead(); | |
| 148 | render( mDocument.toString() ); | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Attaches the HTML head prefix and HTML tail suffix to the given HTML | |
| 153 | * string. | |
| 154 | * | |
| 155 | * @param html The HTML to adorn with opening and closing tags. | |
| 156 | * @return A complete HTML document, ready for rendering. | |
| 157 | */ | |
| 158 | private String decorate( final String html ) { | |
| 159 | mDocument.setLength( 0 ); | |
| 160 | mDocument.append( html ); | |
| 161 | ||
| 162 | // Head and tail must be separate from document due to re-rendering. | |
| 163 | return mHead + mDocument.toString() + HTML_TAIL; | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Called when settings are changed that affect the HTML document preamble. | |
| 168 | * This is a minor performance optimization to avoid generating the head | |
| 169 | * each time that the document itself changes. | |
| 170 | * | |
| 171 | * @return A new doctype and HTML {@code head} element. | |
| 172 | */ | |
| 173 | private String generateHead() { | |
| 174 | final var locale = getLocale(); | |
| 175 | final var url = toUrl( locale ); | |
| 176 | final var base = getBaseUri(); | |
| 177 | ||
| 178 | // Point sizes are converted to pixels because of a rendering bug. | |
| 179 | return format( | |
| 180 | HTML_HEAD, | |
| 181 | locale.getLanguage(), | |
| 182 | format( HTML_STYLESHEET, HTML_STYLE_PREVIEW ), | |
| 183 | url == null ? "" : format( HTML_STYLESHEET, url ), | |
| 184 | getFontFamily(), | |
| 185 | (int) (getFontSize() * (1 + 1 / 3f)), | |
| 186 | base.isBlank() ? "" : format( HTML_BASE, base ) | |
| 187 | ); | |
| 188 | } | |
| 189 | ||
| 190 | /** | |
| 191 | * Clears the preview pane by rendering an empty string. | |
| 192 | */ | |
| 193 | public void clear() { | |
| 194 | render( "" ); | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Sets the base URI to the containing directory the file being edited. | |
| 199 | * | |
| 200 | * @param path The path to the file being edited. | |
| 201 | */ | |
| 202 | public void setBaseUri( final Path path ) { | |
| 203 | final var parent = path.getParent(); | |
| 204 | mBaseUriPath = parent == null ? "" : parent.toUri().toString(); | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * Scrolls to the closest element matching the given identifier without | |
| 209 | * waiting for the document to be ready. | |
| 210 | * | |
| 211 | * @param id Scroll the preview pane to this unique paragraph identifier. | |
| 212 | */ | |
| 213 | public void scrollTo( final String id ) { | |
| 214 | final Runnable scrollToBox = () -> { | |
| 215 | int iter = 0; | |
| 216 | Box box = null; | |
| 217 | ||
| 218 | while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) { | |
| 219 | try { | |
| 220 | sleep( 10 ); | |
| 221 | } catch( final Exception ex ) { | |
| 222 | clue( ex ); | |
| 223 | } | |
| 224 | } | |
| 225 | ||
| 226 | scrollTo( box ); | |
| 227 | }; | |
| 228 | ||
| 229 | if( Platform.isFxApplicationThread() ) { | |
| 230 | scrollToBox.run(); | |
| 231 | } | |
| 232 | else { | |
| 233 | runLater( scrollToBox ); | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * Scrolls to the location specified by the {@link Box} that corresponds | |
| 239 | * to a point somewhere in the preview pane. If there is no caret, then | |
| 240 | * this will not change the scroll position. Changing the scroll position | |
| 241 | * to the top if the {@link Box} instance is {@code null} will result in | |
| 242 | * jumping around a lot and inconsistent synchronization issues. | |
| 243 | * | |
| 244 | * @param box The rectangular region containing the caret, or {@code null} | |
| 245 | * if the HTML does not have a caret. | |
| 246 | */ | |
| 247 | private void scrollTo( final Box box ) { | |
| 248 | if( box != null ) { | |
| 249 | scrollTo( createPoint( box ) ); | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | private void scrollTo( final Point point ) { | |
| 254 | invokeLater( () -> { | |
| 255 | mView.scrollTo( point ); | |
| 256 | getScrollPane().repaint(); | |
| 257 | } ); | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Creates a {@link Point} to use as a reference for scrolling to the area | |
| 262 | * described by the given {@link Box}. The {@link Box} coordinates are used | |
| 263 | * to populate the {@link Point}'s location, with minor adjustments for | |
| 264 | * vertical centering. | |
| 265 | * | |
| 266 | * @param box The {@link Box} that represents a scrolling anchor reference. | |
| 267 | * @return A coordinate suitable for scrolling to. | |
| 268 | */ | |
| 269 | private Point createPoint( final Box box ) { | |
| 270 | assert box != null; | |
| 271 | ||
| 272 | // Scroll back up by half the height of the scroll bar to keep the typing | |
| 273 | // area within the view port. Otherwise the view port will have jumped too | |
| 274 | // high up and the most recently typed letters won't be visible. | |
| 275 | int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 ); | |
| 276 | int x = box.getAbsX(); | |
| 277 | ||
| 278 | if( !box.getStyle().isInline() ) { | |
| 279 | final var margin = box.getMargin( mView.getLayoutContext() ); | |
| 280 | y += margin.top(); | |
| 281 | x += margin.left(); | |
| 282 | } | |
| 283 | ||
| 284 | return new Point( x, y ); | |
| 285 | } | |
| 286 | ||
| 287 | private String getBaseUri() { | |
| 288 | return mBaseUriPath; | |
| 289 | } | |
| 290 | ||
| 291 | private JScrollPane getScrollPane() { | |
| 292 | return mScrollPane; | |
| 293 | } | |
| 294 | ||
| 295 | public JScrollBar getVerticalScrollBar() { | |
| 296 | return getScrollPane().getVerticalScrollBar(); | |
| 297 | } | |
| 298 | ||
| 299 | private int getVerticalScrollBarHeight() { | |
| 300 | return getVerticalScrollBar().getHeight(); | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen | |
| 305 | * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166 | |
| 306 | * alpha-2 country code or UN M.49 numeric-3 area code. For example, this | |
| 307 | * could return "en-Latn-CA" for Canadian English written in the Latin | |
| 308 | * character set. | |
| 309 | * | |
| 310 | * @return Unique identifier for language and country. | |
| 311 | */ | |
| 312 | private static URL toUrl( final Locale locale ) { | |
| 313 | return toUrl( | |
| 314 | get( | |
| 315 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), | |
| 316 | locale.getLanguage(), | |
| 317 | locale.getScript(), | |
| 318 | locale.getCountry() | |
| 319 | ) | |
| 320 | ); | |
| 321 | } | |
| 322 | ||
| 323 | private static URL toUrl( final String path ) { | |
| 324 | return HtmlPreview.class.getResource( path ); | |
| 325 | } | |
| 326 | ||
| 327 | private Locale getLocale() { | |
| 328 | return localeProperty().toLocale(); | |
| 329 | } | |
| 330 | ||
| 331 | private LocaleProperty localeProperty() { | |
| 332 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 333 | } | |
| 334 | ||
| 335 | private String getFontFamily() { | |
| 336 | return fontFamilyProperty().get(); | |
| 337 | } | |
| 338 | ||
| 339 | private StringProperty fontFamilyProperty() { | |
| 340 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 341 | } | |
| 342 | ||
| 343 | private double getFontSize() { | |
| 344 | return fontSizeProperty().get(); | |
| 345 | } | |
| 346 | ||
| 347 | /** | |
| 348 | * Returns the font size in points. | |
| 349 | * | |
| 339 | 350 | * @return The user-defined font size (in pt). |
| 340 | 351 | */ |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.tree; | |
| 3 | ||
| 4 | import javafx.beans.property.Property; | |
| 5 | import javafx.beans.property.SimpleStringProperty; | |
| 6 | import javafx.beans.value.ChangeListener; | |
| 7 | import javafx.beans.value.ObservableValue; | |
| 8 | import javafx.event.EventHandler; | |
| 9 | import javafx.scene.control.TextField; | |
| 10 | import javafx.scene.control.cell.TextFieldTreeCell; | |
| 11 | import javafx.scene.input.KeyEvent; | |
| 12 | import javafx.util.StringConverter; | |
| 13 | ||
| 14 | import static javafx.application.Platform.runLater; | |
| 15 | import static javafx.scene.input.KeyCode.ENTER; | |
| 16 | import static javafx.scene.input.KeyCode.TAB; | |
| 17 | import static javafx.scene.input.KeyEvent.KEY_RELEASED; | |
| 18 | ||
| 19 | /** | |
| 20 | * Responsible for enhancing the existing cell behaviour with fairly common | |
| 21 | * functionality, including commit on focus loss and Enter to commit. | |
| 22 | * | |
| 23 | * @param <T> The type of data stored by the tree. | |
| 24 | */ | |
| 25 | public class AltTreeCell<T> extends TextFieldTreeCell<T> { | |
| 26 | private final KeyHandler mKeyHandler = new KeyHandler(); | |
| 27 | private final Property<String> mInputText = new SimpleStringProperty(); | |
| 28 | private FocusListener mFocusListener; | |
| 29 | ||
| 30 | public AltTreeCell( final StringConverter<T> converter ) { | |
| 31 | super( converter ); | |
| 32 | assert converter != null; | |
| 33 | ||
| 34 | // When the text field is added as the graphics context, we hook into | |
| 35 | // the changed value to get a handle on the text field. From there it is | |
| 36 | // possible to add change the keyboard and focus behaviours. | |
| 37 | graphicProperty().addListener( ( c, o, n ) -> { | |
| 38 | if( o instanceof TextField ) { | |
| 39 | o.removeEventHandler( KEY_RELEASED, mKeyHandler ); | |
| 40 | o.focusedProperty().removeListener( mFocusListener ); | |
| 41 | } | |
| 42 | ||
| 43 | if( n instanceof TextField ) { | |
| 44 | n.addEventFilter( KEY_RELEASED, mKeyHandler ); | |
| 45 | final var input = (TextField) n; | |
| 46 | mInputText.bind( input.textProperty() ); | |
| 47 | mFocusListener = new FocusListener( input ); | |
| 48 | n.focusedProperty().addListener( mFocusListener ); | |
| 49 | } | |
| 50 | } ); | |
| 51 | } | |
| 52 | ||
| 53 | private void commitEdit() { | |
| 54 | commitEdit( getConverter().fromString( mInputText.getValue() ) ); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Responsible for accepting the text when users press the Enter or Tab key. | |
| 59 | */ | |
| 60 | private class KeyHandler implements EventHandler<KeyEvent> { | |
| 61 | @Override | |
| 62 | public void handle( final KeyEvent event ) { | |
| 63 | if( event.getCode() == ENTER || event.getCode() == TAB ) { | |
| 64 | commitEdit(); | |
| 65 | event.consume(); | |
| 66 | } | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Responsible for committing edits when focus is lost. This will also | |
| 72 | * deselect the input field when focus is gained so that typing text won't | |
| 73 | * overwrite the entire existing text. | |
| 74 | */ | |
| 75 | private class FocusListener implements ChangeListener<Boolean> { | |
| 76 | private final TextField mInput; | |
| 77 | ||
| 78 | private FocusListener( final TextField input ) { | |
| 79 | mInput = input; | |
| 80 | } | |
| 81 | ||
| 82 | @Override | |
| 83 | public void changed( | |
| 84 | final ObservableValue<? extends Boolean> c, | |
| 85 | final Boolean endedFocus, final Boolean beganFocus ) { | |
| 86 | ||
| 87 | if( beganFocus ) { | |
| 88 | runLater( mInput::deselect ); | |
| 89 | } | |
| 90 | else if( endedFocus ) { | |
| 91 | commitEdit(); | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | } | |
| 1 | 96 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.tree; | |
| 3 | ||
| 4 | import javafx.scene.control.TreeCell; | |
| 5 | import javafx.scene.control.TreeView; | |
| 6 | import javafx.util.Callback; | |
| 7 | import javafx.util.StringConverter; | |
| 8 | ||
| 9 | //import javafx.collections.ObservableList; | |
| 10 | //import javafx.scene.control.TreeCell; | |
| 11 | //import javafx.scene.control.TreeItem; | |
| 12 | //import javafx.scene.control.TreeView; | |
| 13 | //import javafx.scene.input.ClipboardContent; | |
| 14 | //import javafx.scene.input.DataFormat; | |
| 15 | //import javafx.scene.input.DragEvent; | |
| 16 | //import javafx.scene.input.MouseEvent; | |
| 17 | //import javafx.util.StringConverter; | |
| 18 | //import java.util.Objects; | |
| 19 | //import static javafx.scene.input.TransferMode.MOVE; | |
| 20 | ||
| 21 | /** | |
| 22 | * Responsible for creating new {@link TreeCell} instances. | |
| 23 | * | |
| 24 | * @param <T> The data type stored in the tree. | |
| 25 | */ | |
| 26 | public class AltTreeCellFactory<T> | |
| 27 | implements Callback<TreeView<T>, TreeCell<T>> { | |
| 28 | private final StringConverter<T> mConverter; | |
| 29 | ||
| 30 | public AltTreeCellFactory( final StringConverter<T> converter ) { | |
| 31 | mConverter = converter; | |
| 32 | } | |
| 33 | ||
| 34 | @Override | |
| 35 | public TreeCell<T> call( final TreeView<T> treeView ) { | |
| 36 | return new AltTreeCell<>( mConverter ); | |
| 37 | } | |
| 38 | ||
| 39 | // private static final String STYLE_CLASS_DROP_TARGET = "drop-target"; | |
| 40 | // private static final DataFormat JAVA_FORMAT = | |
| 41 | // new DataFormat( APP_JAVA_OBJECT.toString() ); | |
| 42 | // | |
| 43 | // private TreeItem<String> mDraggedTreeItem; | |
| 44 | // private TreeCell<String> mTargetCell; | |
| 45 | // | |
| 46 | // @Override | |
| 47 | // public TreeCell<String> call( final TreeView<String> treeView ) { | |
| 48 | // final var cell = createTreeCell(); | |
| 49 | // | |
| 50 | // cell.setOnDragDetected( event -> dragDetected( event, cell ) ); | |
| 51 | // cell.setOnDragOver( event -> dragOver( event, cell ) ); | |
| 52 | // cell.setOnDragDropped( event -> dragDropped( event, cell, treeView ) ); | |
| 53 | // cell.setOnDragDone( event -> dragClear() ); | |
| 54 | // | |
| 55 | // return cell; | |
| 56 | // } | |
| 57 | // | |
| 58 | // private TreeCell<String> createTreeCell() { | |
| 59 | // } | |
| 60 | // | |
| 61 | // /** | |
| 62 | // * Drag start. | |
| 63 | // * | |
| 64 | // * @param event The drag start {@link MouseEvent}. | |
| 65 | // * @param treeCell The cell being dragged. | |
| 66 | // */ | |
| 67 | //private void dragDetected( | |
| 68 | // final MouseEvent event, final TreeCell<String> treeCell ) { | |
| 69 | // final var sourceItem = treeCell.getTreeItem(); | |
| 70 | // | |
| 71 | // // Prevent dragging the root item. | |
| 72 | // if( sourceItem != null && sourceItem.getParent() != null ) { | |
| 73 | // final var dragboard = treeCell.startDragAndDrop( MOVE ); | |
| 74 | // final var clipboard = new ClipboardContent(); | |
| 75 | // clipboard.put( JAVA_FORMAT, sourceItem.getValue() ); | |
| 76 | // dragboard.setContent( clipboard ); | |
| 77 | // dragboard.setDragView( treeCell.snapshot( null, null ) ); | |
| 78 | // event.consume(); | |
| 79 | // | |
| 80 | // mDraggedTreeItem = sourceItem; | |
| 81 | // } | |
| 82 | //} | |
| 83 | // | |
| 84 | // /** | |
| 85 | // * Drag over another {@link TreeCell} instance. | |
| 86 | // * | |
| 87 | // * @param event The drag over {@link DragEvent}. | |
| 88 | // * @param treeCell The cell dragged over. | |
| 89 | // * @throws IllegalStateException Drag transfer "move" mode denied. | |
| 90 | // */ | |
| 91 | // private void dragOver( | |
| 92 | // final DragEvent event, final TreeCell<String> treeCell ) { | |
| 93 | // if( event.getDragboard().hasContent( JAVA_FORMAT ) ) { | |
| 94 | // final var thisItem = treeCell.getTreeItem(); | |
| 95 | // | |
| 96 | // if( mDraggedTreeItem == null || | |
| 97 | // thisItem == null || | |
| 98 | // thisItem == mDraggedTreeItem ) { | |
| 99 | // return; | |
| 100 | // } | |
| 101 | // | |
| 102 | // // Ignore dragging over the root item. | |
| 103 | // if( mDraggedTreeItem.getParent() == null ) { | |
| 104 | // dragClear(); | |
| 105 | // return; | |
| 106 | // } | |
| 107 | // | |
| 108 | // event.acceptTransferModes( MOVE ); | |
| 109 | // | |
| 110 | // if( !Objects.equals( mTargetCell, treeCell ) ) { | |
| 111 | // dragClear(); | |
| 112 | // mTargetCell = treeCell; | |
| 113 | // mTargetCell.getStyleClass().add( STYLE_CLASS_DROP_TARGET ); | |
| 114 | // } | |
| 115 | // } | |
| 116 | // } | |
| 117 | // | |
| 118 | // /** | |
| 119 | // * Dragged item is dropped | |
| 120 | // * | |
| 121 | // * @param event The drag dropped {@link DragEvent}. | |
| 122 | // * @param treeCell The cell dropped onto. | |
| 123 | // */ | |
| 124 | // private void dragDropped( final DragEvent event, | |
| 125 | // final TreeCell<String> treeCell, | |
| 126 | // final TreeView<String> treeView ) { | |
| 127 | // if( !event.getDragboard().hasContent( JAVA_FORMAT ) ) { | |
| 128 | // return; | |
| 129 | // } | |
| 130 | // | |
| 131 | // final var sourceItem = mDraggedTreeItem; | |
| 132 | // final var sourceItemParent = mDraggedTreeItem.getParent(); | |
| 133 | // final var targetItem = treeCell.getTreeItem(); | |
| 134 | // final var targetItemParent = targetItem.getParent(); | |
| 135 | // | |
| 136 | // sourceItemParent.getChildren().remove( sourceItem ); | |
| 137 | // | |
| 138 | // final ObservableList<TreeItem<String>> children; | |
| 139 | // final int index; | |
| 140 | // | |
| 141 | // // Dropping onto a parent node makes the source item the first child. | |
| 142 | // if( Objects.equals( sourceItemParent, targetItem ) ) { | |
| 143 | // children = targetItem.getChildren(); | |
| 144 | // index = 0; | |
| 145 | // } | |
| 146 | // else if( targetItemParent != null) { | |
| 147 | // children = targetItemParent.getChildren(); | |
| 148 | // index = children.indexOf( targetItem ) + 1; | |
| 149 | // } | |
| 150 | // else { | |
| 151 | // children = sourceItemParent.getChildren(); | |
| 152 | // index = 0; | |
| 153 | // } | |
| 154 | // | |
| 155 | // children.add( index, sourceItem ); | |
| 156 | // | |
| 157 | // treeView.getSelectionModel().clearSelection(); | |
| 158 | // treeView.getSelectionModel().select( sourceItem ); | |
| 159 | // | |
| 160 | // // TODO: Notify a listener of the old and new tree item position. | |
| 161 | // | |
| 162 | // event.setDropCompleted( true ); | |
| 163 | // } | |
| 164 | // | |
| 165 | // private void dragClear() { | |
| 166 | // final var targetCell = mTargetCell; | |
| 167 | // | |
| 168 | // if( targetCell != null ) { | |
| 169 | // targetCell.getStyleClass().remove( STYLE_CLASS_DROP_TARGET ); | |
| 170 | // } | |
| 171 | // } | |
| 172 | } | |
| 1 | 173 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.tree; | |
| 3 | ||
| 4 | import javafx.scene.control.TreeItem; | |
| 5 | import javafx.scene.control.TreeView; | |
| 6 | import javafx.util.StringConverter; | |
| 7 | ||
| 8 | /** | |
| 9 | * Responsible for allowing users to edit items in the tree as well as | |
| 10 | * drag and drop. The goal is to be a drop-in replacement for the regular | |
| 11 | * JavaFX {@link TreeView} that does not offer editing and moving {@link | |
| 12 | * TreeItem} instances. | |
| 13 | * | |
| 14 | * @param <T> The type of data to edit. | |
| 15 | */ | |
| 16 | public class AltTreeView<T> extends TreeView<T> { | |
| 17 | public AltTreeView( | |
| 18 | final TreeItem<T> root, final StringConverter<T> converter ) { | |
| 19 | super( root ); | |
| 20 | ||
| 21 | setEditable( true ); | |
| 22 | setCellFactory( new AltTreeCellFactory<>( converter ) ); | |
| 23 | setShowRoot( false ); | |
| 24 | ||
| 25 | // When focus is lost, clear the selected item only when not editing. | |
| 26 | focusedProperty().addListener( ( c, o, n ) -> { | |
| 27 | if( o && getEditingItem() == null ) { | |
| 28 | getSelectionModel().clearSelection(); | |
| 29 | } | |
| 30 | } ); | |
| 31 | } | |
| 32 | } | |
| 1 | 33 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.tree; | |
| 3 | ||
| 4 | import javafx.util.StringConverter; | |
| 5 | ||
| 6 | /** | |
| 7 | * Responsible for converting objects to and from string instances. The | |
| 8 | * tree items contain only strings, so this effectively is a string-to-string | |
| 9 | * converter, which allows the implementation to retain its generics. | |
| 10 | */ | |
| 11 | public class TreeItemConverter extends StringConverter<String> { | |
| 12 | ||
| 13 | @Override | |
| 14 | public String toString( final String object ) { | |
| 15 | return sanitize( object ); | |
| 16 | } | |
| 17 | ||
| 18 | @Override | |
| 19 | public String fromString( final String string ) { | |
| 20 | return sanitize( string ); | |
| 21 | } | |
| 22 | ||
| 23 | private String sanitize( final String string ) { | |
| 24 | return string == null ? "" : string; | |
| 25 | } | |
| 26 | } | |
| 1 | 27 |
| 13 | 13 | Main.menu.insert=_Insert |
| 14 | 14 | Main.menu.format=Forma_t |
| 15 | Main.menu.definition=_Definition | |
| 16 | Main.menu.view=_View | |
| 17 | Main.menu.help=_Help | |
| 18 | ||
| 19 | # ######################################################################## | |
| 20 | # Detachable Tabs | |
| 21 | # ######################################################################## | |
| 22 | ||
| 23 | # {0} is the application title; {1} is a unique window ID. | |
| 24 | Detach.tab.title={0} - {1} | |
| 25 | ||
| 26 | # ######################################################################## | |
| 27 | # Status Bar | |
| 28 | # ######################################################################## | |
| 29 | ||
| 30 | Main.status.text.offset=offset | |
| 31 | Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2} | |
| 32 | Main.status.state.default=OK | |
| 33 | Main.status.export.success=Saved as {0} | |
| 34 | ||
| 35 | Main.status.error.bootstrap.eval=Note: Bootstrap definition of ''{0}'' not found | |
| 36 | ||
| 37 | Main.status.error.parse={0} (near ${Main.status.text.offset} {1}) | |
| 38 | Main.status.error.def.blank=Move the caret to a word before inserting a definition | |
| 39 | Main.status.error.def.empty=Create a definition before inserting a definition | |
| 40 | Main.status.error.def.missing=No definition value found for ''{0}'' | |
| 41 | Main.status.error.r=Error with [{0}...]: {1} | |
| 42 | Main.status.error.file.missing=Not found: {0} | |
| 43 | ||
| 44 | Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}'' | |
| 45 | Main.status.error.messages.syntax=Missing ''}'' in ''{0}'' | |
| 46 | ||
| 47 | Main.status.error.undo=Cannot undo; beginning of undo history reached | |
| 48 | Main.status.error.redo=Cannot redo; end of redo history reached | |
| 49 | ||
| 50 | Main.status.image.request.init=Initializing HTTP request | |
| 51 | Main.status.image.request.fetch=Requesting content type from {0} | |
| 52 | Main.status.image.request.success=Determined content type ''{0}'' | |
| 53 | Main.status.image.request.error.media=No media type for ''{0}'' | |
| 54 | Main.status.image.request.error.cert=Could not accept certificate for ''{0}'' | |
| 55 | ||
| 56 | Main.status.font.search.missing=No font name starting with ''{0}'' was found | |
| 57 | ||
| 58 | # ######################################################################## | |
| 59 | # Search Bar | |
| 60 | # ######################################################################## | |
| 61 | ||
| 62 | Main.search.stop.tooltip=Close search bar | |
| 63 | Main.search.stop.icon=CLOSE | |
| 64 | Main.search.next.tooltip=Find next match | |
| 65 | Main.search.next.icon=CHEVRON_DOWN | |
| 66 | Main.search.prev.tooltip=Find previous match | |
| 67 | Main.search.prev.icon=CHEVRON_UP | |
| 68 | Main.search.find.tooltip=Search document for text | |
| 69 | Main.search.find.icon=SEARCH | |
| 70 | Main.search.match.none=No matches | |
| 71 | Main.search.match.some={0} of {1} matches | |
| 72 | ||
| 73 | # ######################################################################## | |
| 74 | # Workspace preferences | |
| 75 | # ######################################################################## | |
| 76 | ||
| 77 | workspace.r=R | |
| 78 | workspace.r.script=Startup Script | |
| 79 | workspace.r.script.desc=Script runs prior to executing R statements within the document. | |
| 80 | workspace.r.dir=Working Directory | |
| 81 | workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script. | |
| 82 | workspace.r.dir.title=Directory | |
| 83 | workspace.r.delimiter.began=Delimiter Prefix | |
| 84 | workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted definitions. | |
| 85 | workspace.r.delimiter.began.title=Opening | |
| 86 | workspace.r.delimiter.ended=Delimiter Suffix | |
| 87 | workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted definitions. | |
| 88 | workspace.r.delimiter.ended.title=Closing | |
| 89 | ||
| 90 | workspace.images=Images | |
| 91 | workspace.images.dir=Absolute Directory | |
| 92 | workspace.images.dir.desc=Path to search for local file system images. | |
| 93 | workspace.images.dir.title=Directory | |
| 94 | workspace.images.order=Extensions | |
| 95 | workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces. | |
| 96 | workspace.images.order.title=Extensions | |
| 97 | ||
| 98 | workspace.definition=Definition | |
| 99 | workspace.definition.path=File name | |
| 100 | workspace.definition.path.desc=Absolute path to interpolated string definition. | |
| 101 | workspace.definition.path.title=Path | |
| 102 | workspace.definition.delimiter.began=Delimiter Prefix | |
| 103 | workspace.definition.delimiter.began.desc=Indicates when a definition key is starting. | |
| 104 | workspace.definition.delimiter.began.title=Opening | |
| 105 | workspace.definition.delimiter.ended=Delimiter Suffix | |
| 106 | workspace.definition.delimiter.ended.desc=Indicates when a definition key is ending. | |
| 107 | workspace.definition.delimiter.ended.title=Closing | |
| 108 | ||
| 109 | workspace.ui.theme=Themes | |
| 110 | workspace.ui.theme.selection=Bundled | |
| 111 | workspace.ui.theme.selection.desc=Pre-packaged application style (default: Modena Light) | |
| 112 | workspace.ui.theme.selection.title=Name | |
| 113 | workspace.ui.theme.custom=Custom | |
| 114 | workspace.ui.theme.custom.desc=User-defined JavaFX cascading stylesheet file | |
| 115 | workspace.ui.theme.custom.title=Path | |
| 116 | ||
| 117 | workspace.ui.font=Fonts | |
| 118 | workspace.ui.font.editor=Editor Font | |
| 119 | workspace.ui.font.editor.name=Name | |
| 120 | workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended). | |
| 121 | workspace.ui.font.editor.name.title=Family | |
| 122 | workspace.ui.font.editor.size=Size | |
| 123 | workspace.ui.font.editor.size.desc=Font size. | |
| 124 | workspace.ui.font.editor.size.title=Points | |
| 125 | workspace.ui.font.preview=Preview Font | |
| 126 | workspace.ui.font.preview.name=Name | |
| 127 | workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended). | |
| 128 | workspace.ui.font.preview.name.title=Family | |
| 129 | workspace.ui.font.preview.size=Size | |
| 130 | workspace.ui.font.preview.size.desc=Font size. | |
| 131 | workspace.ui.font.preview.size.title=Points | |
| 132 | workspace.ui.font.preview.mono.name=Name | |
| 133 | workspace.ui.font.preview.mono.name.desc=Monospace font name. | |
| 134 | workspace.ui.font.preview.mono.name.title=Family | |
| 135 | workspace.ui.font.preview.mono.size=Size | |
| 136 | workspace.ui.font.preview.mono.size.desc=Monospace font size. | |
| 137 | workspace.ui.font.preview.mono.size.title=Points | |
| 138 | ||
| 139 | workspace.language=Language | |
| 140 | workspace.language.locale=Internationalization | |
| 141 | workspace.language.locale.desc=Language for application and HTML export. | |
| 142 | workspace.language.locale.title=Locale | |
| 143 | ||
| 144 | # ######################################################################## | |
| 145 | # Definition Pane and its Tree View | |
| 146 | # ######################################################################## | |
| 147 | ||
| 148 | Definition.menu.add.default=Undefined | |
| 149 | ||
| 150 | # ######################################################################## | |
| 151 | # Definition Pane | |
| 152 | # ######################################################################## | |
| 153 | ||
| 154 | Pane.definition.node.root.title=Definitions | |
| 155 | ||
| 156 | # ######################################################################## | |
| 157 | # Failure messages with respect to YAML files. | |
| 158 | # ######################################################################## | |
| 159 | ||
| 160 | yaml.error.open=Could not open YAML file (ensure non-empty file). | |
| 161 | yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | |
| 162 | yaml.error.missing=Empty definition value for key ''{0}''. | |
| 163 | yaml.error.tree.form=Unassigned definition near ''{0}''. | |
| 164 | ||
| 165 | # ######################################################################## | |
| 166 | # Text Resource | |
| 167 | # ######################################################################## | |
| 168 | ||
| 169 | TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | |
| 170 | TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | |
| 171 | ||
| 172 | # ######################################################################## | |
| 173 | # Text Resources | |
| 174 | # ######################################################################## | |
| 175 | ||
| 176 | TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | |
| 177 | TextResource.saveFailed.title=Save | |
| 178 | ||
| 179 | # ######################################################################## | |
| 180 | # File Open | |
| 181 | # ######################################################################## | |
| 182 | ||
| 183 | Dialog.file.choose.open.title=Open File | |
| 184 | Dialog.file.choose.save.title=Save File | |
| 185 | Dialog.file.choose.export.title=Export File | |
| 186 | ||
| 187 | Dialog.file.choose.filter.title.source=Source Files | |
| 188 | Dialog.file.choose.filter.title.definition=Definition Files | |
| 189 | Dialog.file.choose.filter.title.xml=XML Files | |
| 190 | Dialog.file.choose.filter.title.all=All Files | |
| 191 | ||
| 192 | # ######################################################################## | |
| 193 | # Browse File | |
| 194 | # ######################################################################## | |
| 195 | ||
| 196 | BrowseFileButton.chooser.title=Browse for local file | |
| 197 | BrowseFileButton.chooser.allFilesFilter=All Files | |
| 198 | BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | |
| 199 | ||
| 200 | # ######################################################################## | |
| 201 | # Alert Dialog | |
| 202 | # ######################################################################## | |
| 203 | ||
| 204 | Alert.file.close.title=Close | |
| 205 | Alert.file.close.text=Save changes to {0}? | |
| 206 | ||
| 207 | # ######################################################################## | |
| 208 | # Image Dialog | |
| 209 | # ######################################################################## | |
| 210 | ||
| 211 | Dialog.image.title=Image | |
| 212 | Dialog.image.chooser.imagesFilter=Images | |
| 213 | Dialog.image.previewLabel.text=Markdown Preview\: | |
| 214 | Dialog.image.textLabel.text=Alternate Text\: | |
| 215 | Dialog.image.titleLabel.text=Title (tooltip)\: | |
| 216 | Dialog.image.urlLabel.text=Image URL\: | |
| 217 | ||
| 218 | # ######################################################################## | |
| 219 | # Hyperlink Dialog | |
| 220 | # ######################################################################## | |
| 221 | ||
| 222 | Dialog.link.title=Link | |
| 223 | Dialog.link.previewLabel.text=Markdown Preview\: | |
| 224 | Dialog.link.textLabel.text=Link Text\: | |
| 225 | Dialog.link.titleLabel.text=Title (tooltip)\: | |
| 226 | Dialog.link.urlLabel.text=Link URL\: | |
| 227 | ||
| 228 | # ######################################################################## | |
| 229 | # About Dialog | |
| 230 | # ######################################################################## | |
| 231 | ||
| 232 | Dialog.about.title=About {0} | |
| 233 | Dialog.about.header={0} | |
| 234 | Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1} | |
| 235 | ||
| 236 | # ######################################################################## | |
| 237 | # Application Actions | |
| 238 | # ######################################################################## | |
| 239 | ||
| 240 | App.action.file.new.description=Create a new file | |
| 241 | App.action.file.new.accelerator=Shortcut+N | |
| 242 | App.action.file.new.icon=FILE_ALT | |
| 243 | App.action.file.new.text=_New | |
| 244 | ||
| 245 | App.action.file.open.description=Open a new file | |
| 246 | App.action.file.open.accelerator=Shortcut+O | |
| 247 | App.action.file.open.text=_Open... | |
| 248 | App.action.file.open.icon=FOLDER_OPEN_ALT | |
| 249 | ||
| 250 | App.action.file.close.description=Close the current document | |
| 251 | App.action.file.close.accelerator=Shortcut+W | |
| 252 | App.action.file.close.text=_Close | |
| 253 | ||
| 254 | App.action.file.close_all.description=Close all open documents | |
| 255 | App.action.file.close_all.accelerator=Ctrl+F4 | |
| 256 | App.action.file.close_all.text=Close All | |
| 257 | ||
| 258 | App.action.file.save.description=Save the document | |
| 259 | App.action.file.save.accelerator=Shortcut+S | |
| 260 | App.action.file.save.text=_Save | |
| 261 | App.action.file.save.icon=FLOPPY_ALT | |
| 262 | ||
| 263 | App.action.file.save_as.description=Rename the current document | |
| 264 | App.action.file.save_as.text=Save _As | |
| 265 | ||
| 266 | App.action.file.save_all.description=Save all open documents | |
| 267 | App.action.file.save_all.accelerator=Shortcut+Shift+S | |
| 268 | App.action.file.save_all.text=Save A_ll | |
| 269 | ||
| 270 | App.action.file.export.html_svg.description=Export the current document as HTML + SVG | |
| 271 | App.action.file.export.text=_Export As | |
| 272 | App.action.file.export.html_svg.text=HTML and S_VG | |
| 273 | ||
| 274 | App.action.file.export.html_tex.description=Export the current document as HTML + TeX | |
| 275 | App.action.file.export.html_tex.text=HTML and _TeX | |
| 276 | ||
| 277 | App.action.file.export.markdown.description=Export the current document as Markdown | |
| 278 | App.action.file.export.markdown.text=Markdown | |
| 279 | ||
| 280 | App.action.file.exit.description=Quit the application | |
| 281 | App.action.file.exit.text=E_xit | |
| 282 | ||
| 283 | ||
| 284 | App.action.edit.undo.description=Undo the previous edit | |
| 285 | App.action.edit.undo.accelerator=Shortcut+Z | |
| 286 | App.action.edit.undo.text=_Undo | |
| 287 | App.action.edit.undo.icon=UNDO | |
| 288 | ||
| 289 | App.action.edit.redo.description=Redo the previous edit | |
| 290 | App.action.edit.redo.accelerator=Shortcut+Y | |
| 291 | App.action.edit.redo.text=_Redo | |
| 292 | App.action.edit.redo.icon=REPEAT | |
| 293 | ||
| 294 | App.action.edit.cut.description=Delete the selected text or line | |
| 295 | App.action.edit.cut.accelerator=Shortcut+X | |
| 296 | App.action.edit.cut.text=Cu_t | |
| 297 | App.action.edit.cut.icon=CUT | |
| 298 | ||
| 299 | App.action.edit.copy.description=Copy the selected text | |
| 300 | App.action.edit.copy.accelerator=Shortcut+C | |
| 301 | App.action.edit.copy.text=_Copy | |
| 302 | App.action.edit.copy.icon=COPY | |
| 303 | ||
| 304 | App.action.edit.paste.description=Paste from the clipboard | |
| 305 | App.action.edit.paste.accelerator=Shortcut+V | |
| 306 | App.action.edit.paste.text=_Paste | |
| 307 | App.action.edit.paste.icon=PASTE | |
| 308 | ||
| 309 | App.action.edit.select_all.description=Highlight the current document text | |
| 310 | App.action.edit.select_all.accelerator=Shortcut+A | |
| 311 | App.action.edit.select_all.text=Select _All | |
| 312 | ||
| 313 | App.action.edit.find.description=Search for text in the document | |
| 314 | App.action.edit.find.accelerator=Shortcut+F | |
| 315 | App.action.edit.find.text=_Find | |
| 316 | App.action.edit.find.icon=SEARCH | |
| 317 | ||
| 318 | App.action.edit.find_next.description=Find next occurrence | |
| 319 | App.action.edit.find_next.accelerator=F3 | |
| 320 | App.action.edit.find_next.text=Find _Next | |
| 321 | ||
| 322 | App.action.edit.find_prev.description=Find previous occurrence | |
| 323 | App.action.edit.find_prev.accelerator=Shift+F3 | |
| 324 | App.action.edit.find_prev.text=Find _Prev | |
| 325 | ||
| 326 | App.action.edit.preferences.description=Edit user preferences | |
| 327 | App.action.edit.preferences.accelerator=Ctrl+Alt+S | |
| 328 | App.action.edit.preferences.text=_Preferences | |
| 329 | ||
| 330 | ||
| 331 | App.action.format.bold.description=Insert strong text | |
| 332 | App.action.format.bold.accelerator=Shortcut+B | |
| 333 | App.action.format.bold.text=_Bold | |
| 334 | App.action.format.bold.icon=BOLD | |
| 335 | ||
| 336 | App.action.format.italic.description=Insert text emphasis | |
| 337 | App.action.format.italic.accelerator=Shortcut+I | |
| 338 | App.action.format.italic.text=_Italic | |
| 339 | App.action.format.italic.icon=ITALIC | |
| 340 | ||
| 341 | App.action.format.superscript.description=Insert superscript text | |
| 342 | App.action.format.superscript.accelerator=Shortcut+[ | |
| 343 | App.action.format.superscript.text=Su_perscript | |
| 344 | App.action.format.superscript.icon=SUPERSCRIPT | |
| 345 | ||
| 346 | App.action.format.subscript.description=Insert subscript text | |
| 347 | App.action.format.subscript.accelerator=Shortcut+] | |
| 348 | App.action.format.subscript.text=Su_bscript | |
| 349 | App.action.format.subscript.icon=SUBSCRIPT | |
| 350 | ||
| 351 | App.action.format.strikethrough.description=Insert struck text | |
| 352 | App.action.format.strikethrough.accelerator=Shortcut+T | |
| 353 | App.action.format.strikethrough.text=Stri_kethrough | |
| 354 | App.action.format.strikethrough.icon=STRIKETHROUGH | |
| 355 | ||
| 356 | ||
| 357 | App.action.insert.blockquote.description=Insert blockquote | |
| 358 | App.action.insert.blockquote.accelerator=Ctrl+Q | |
| 359 | App.action.insert.blockquote.text=_Blockquote | |
| 360 | App.action.insert.blockquote.icon=QUOTE_LEFT | |
| 361 | ||
| 362 | App.action.insert.code.description=Insert inline code | |
| 363 | App.action.insert.code.accelerator=Shortcut+K | |
| 364 | App.action.insert.code.text=Inline _Code | |
| 365 | App.action.insert.code.icon=CODE | |
| 366 | ||
| 367 | App.action.insert.fenced_code_block.description=Insert code block | |
| 368 | App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K | |
| 369 | App.action.insert.fenced_code_block.text=_Fenced Code Block | |
| 370 | App.action.insert.fenced_code_block.prompt.text=Enter code here | |
| 371 | App.action.insert.fenced_code_block.icon=FILE_CODE_ALT | |
| 372 | ||
| 373 | App.action.insert.link.description=Insert hyperlink | |
| 374 | App.action.insert.link.accelerator=Shortcut+L | |
| 375 | App.action.insert.link.text=_Link... | |
| 376 | App.action.insert.link.icon=LINK | |
| 377 | ||
| 378 | App.action.insert.image.description=Insert image | |
| 379 | App.action.insert.image.accelerator=Shortcut+G | |
| 380 | App.action.insert.image.text=_Image... | |
| 381 | App.action.insert.image.icon=PICTURE_ALT | |
| 382 | ||
| 383 | App.action.insert.heading.description=Insert heading level | |
| 384 | App.action.insert.heading.accelerator=Shortcut+ | |
| 385 | App.action.insert.heading.icon=HEADER | |
| 386 | ||
| 387 | App.action.insert.heading_1.description=${App.action.insert.heading.description} 1 | |
| 388 | App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1 | |
| 389 | App.action.insert.heading_1.text=Heading _1 | |
| 390 | App.action.insert.heading_1.icon=${App.action.insert.heading.icon} | |
| 391 | ||
| 392 | App.action.insert.heading_2.description=${App.action.insert.heading.description} 2 | |
| 393 | App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2 | |
| 394 | App.action.insert.heading_2.text=Heading _2 | |
| 395 | App.action.insert.heading_2.icon=${App.action.insert.heading.icon} | |
| 396 | ||
| 397 | App.action.insert.heading_3.description=${App.action.insert.heading.description} 3 | |
| 398 | App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3 | |
| 399 | App.action.insert.heading_3.text=Heading _3 | |
| 400 | App.action.insert.heading_3.icon=${App.action.insert.heading.icon} | |
| 401 | ||
| 402 | App.action.insert.unordered_list.description=Insert bulleted list | |
| 403 | App.action.insert.unordered_list.accelerator=Shortcut+U | |
| 404 | App.action.insert.unordered_list.text=_Unordered List | |
| 405 | App.action.insert.unordered_list.icon=LIST_UL | |
| 406 | ||
| 407 | App.action.insert.ordered_list.description=Insert enumerated list | |
| 408 | App.action.insert.ordered_list.accelerator=Shortcut+Shift+O | |
| 409 | App.action.insert.ordered_list.text=_Ordered List | |
| 410 | App.action.insert.ordered_list.icon=LIST_OL | |
| 411 | ||
| 412 | App.action.insert.horizontal_rule.description=Insert horizontal rule | |
| 413 | App.action.insert.horizontal_rule.accelerator=Shortcut+H | |
| 414 | App.action.insert.horizontal_rule.text=_Horizontal Rule | |
| 415 | App.action.insert.horizontal_rule.icon=LIST_OL | |
| 416 | ||
| 417 | ||
| 418 | App.action.definition.create.description=Create a new variable definition | |
| 419 | App.action.definition.create.text=_Create | |
| 420 | App.action.definition.create.icon=TREE | |
| 421 | App.action.definition.create.tooltip=Add new item (Insert) | |
| 422 | ||
| 423 | App.action.definition.rename.description=Rename the selected variable definition | |
| 424 | App.action.definition.rename.text=_Rename | |
| 425 | App.action.definition.rename.icon=EDIT | |
| 426 | App.action.definition.rename.tooltip=Rename selected item (F2) | |
| 427 | ||
| 428 | App.action.definition.delete.description=Delete the selected variable definitions | |
| 429 | App.action.definition.delete.text=De_lete | |
| 430 | App.action.definition.delete.icon=TRASH | |
| 431 | App.action.definition.delete.tooltip=Delete selected items (Delete) | |
| 432 | ||
| 433 | App.action.definition.insert.description=Insert a definition | |
| 15 | Main.menu.definition=_Variable | |
| 16 | Main.menu.view=Vie_w | |
| 17 | Main.menu.help=_Help | |
| 18 | ||
| 19 | # ######################################################################## | |
| 20 | # Detachable Tabs | |
| 21 | # ######################################################################## | |
| 22 | ||
| 23 | # {0} is the application title; {1} is a unique window ID. | |
| 24 | Detach.tab.title={0} - {1} | |
| 25 | ||
| 26 | # ######################################################################## | |
| 27 | # Status Bar | |
| 28 | # ######################################################################## | |
| 29 | ||
| 30 | Main.status.text.offset=offset | |
| 31 | Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2} | |
| 32 | Main.status.state.default=OK | |
| 33 | Main.status.export.success=Saved as {0} | |
| 34 | ||
| 35 | Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found | |
| 36 | ||
| 37 | Main.status.error.parse={0} (near ${Main.status.text.offset} {1}) | |
| 38 | Main.status.error.def.blank=Move the caret to a word before inserting a variable | |
| 39 | Main.status.error.def.empty=Create a variable before inserting one | |
| 40 | Main.status.error.def.missing=No variable value found for ''{0}'' | |
| 41 | Main.status.error.r=Error with [{0}...]: {1} | |
| 42 | Main.status.error.file.missing=Not found: {0} | |
| 43 | ||
| 44 | Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}'' | |
| 45 | Main.status.error.messages.syntax=Missing ''}'' in ''{0}'' | |
| 46 | ||
| 47 | Main.status.error.undo=Cannot undo; beginning of undo history reached | |
| 48 | Main.status.error.redo=Cannot redo; end of redo history reached | |
| 49 | ||
| 50 | Main.status.image.request.init=Initializing HTTP request | |
| 51 | Main.status.image.request.fetch=Requesting content type from {0} | |
| 52 | Main.status.image.request.success=Determined content type ''{0}'' | |
| 53 | Main.status.image.request.error.media=No media type for ''{0}'' | |
| 54 | Main.status.image.request.error.cert=Could not accept certificate for ''{0}'' | |
| 55 | ||
| 56 | Main.status.font.search.missing=No font name starting with ''{0}'' was found | |
| 57 | ||
| 58 | # ######################################################################## | |
| 59 | # Search Bar | |
| 60 | # ######################################################################## | |
| 61 | ||
| 62 | Main.search.stop.tooltip=Close search bar | |
| 63 | Main.search.stop.icon=CLOSE | |
| 64 | Main.search.next.tooltip=Find next match | |
| 65 | Main.search.next.icon=CHEVRON_DOWN | |
| 66 | Main.search.prev.tooltip=Find previous match | |
| 67 | Main.search.prev.icon=CHEVRON_UP | |
| 68 | Main.search.find.tooltip=Search document for text | |
| 69 | Main.search.find.icon=SEARCH | |
| 70 | Main.search.match.none=No matches | |
| 71 | Main.search.match.some={0} of {1} matches | |
| 72 | ||
| 73 | # ######################################################################## | |
| 74 | # Workspace preferences | |
| 75 | # ######################################################################## | |
| 76 | ||
| 77 | workspace.r=R | |
| 78 | workspace.r.script=Startup Script | |
| 79 | workspace.r.script.desc=Script runs prior to executing R statements within the document. | |
| 80 | workspace.r.dir=Working Directory | |
| 81 | workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script. | |
| 82 | workspace.r.dir.title=Directory | |
| 83 | workspace.r.delimiter.began=Delimiter Prefix | |
| 84 | workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables. | |
| 85 | workspace.r.delimiter.began.title=Opening | |
| 86 | workspace.r.delimiter.ended=Delimiter Suffix | |
| 87 | workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables. | |
| 88 | workspace.r.delimiter.ended.title=Closing | |
| 89 | ||
| 90 | workspace.images=Images | |
| 91 | workspace.images.dir=Absolute Directory | |
| 92 | workspace.images.dir.desc=Path to search for local file system images. | |
| 93 | workspace.images.dir.title=Directory | |
| 94 | workspace.images.order=Extensions | |
| 95 | workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces. | |
| 96 | workspace.images.order.title=Extensions | |
| 97 | ||
| 98 | workspace.definition=Variable | |
| 99 | workspace.definition.path=File name | |
| 100 | workspace.definition.path.desc=Absolute path to interpolated string variables. | |
| 101 | workspace.definition.path.title=Path | |
| 102 | workspace.definition.delimiter.began=Delimiter Prefix | |
| 103 | workspace.definition.delimiter.began.desc=Indicates when a variable name is starting. | |
| 104 | workspace.definition.delimiter.began.title=Opening | |
| 105 | workspace.definition.delimiter.ended=Delimiter Suffix | |
| 106 | workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending. | |
| 107 | workspace.definition.delimiter.ended.title=Closing | |
| 108 | ||
| 109 | workspace.ui.theme=Themes | |
| 110 | workspace.ui.theme.selection=Bundled | |
| 111 | workspace.ui.theme.selection.desc=Pre-packaged application style (default: Modena Light) | |
| 112 | workspace.ui.theme.selection.title=Name | |
| 113 | workspace.ui.theme.custom=Custom | |
| 114 | workspace.ui.theme.custom.desc=User-defined JavaFX cascading stylesheet file | |
| 115 | workspace.ui.theme.custom.title=Path | |
| 116 | ||
| 117 | workspace.ui.font=Fonts | |
| 118 | workspace.ui.font.editor=Editor Font | |
| 119 | workspace.ui.font.editor.name=Name | |
| 120 | workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended). | |
| 121 | workspace.ui.font.editor.name.title=Family | |
| 122 | workspace.ui.font.editor.size=Size | |
| 123 | workspace.ui.font.editor.size.desc=Font size. | |
| 124 | workspace.ui.font.editor.size.title=Points | |
| 125 | workspace.ui.font.preview=Preview Font | |
| 126 | workspace.ui.font.preview.name=Name | |
| 127 | workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended). | |
| 128 | workspace.ui.font.preview.name.title=Family | |
| 129 | workspace.ui.font.preview.size=Size | |
| 130 | workspace.ui.font.preview.size.desc=Font size. | |
| 131 | workspace.ui.font.preview.size.title=Points | |
| 132 | workspace.ui.font.preview.mono.name=Name | |
| 133 | workspace.ui.font.preview.mono.name.desc=Monospace font name. | |
| 134 | workspace.ui.font.preview.mono.name.title=Family | |
| 135 | workspace.ui.font.preview.mono.size=Size | |
| 136 | workspace.ui.font.preview.mono.size.desc=Monospace font size. | |
| 137 | workspace.ui.font.preview.mono.size.title=Points | |
| 138 | ||
| 139 | workspace.language=Language | |
| 140 | workspace.language.locale=Internationalization | |
| 141 | workspace.language.locale.desc=Language for application and HTML export. | |
| 142 | workspace.language.locale.title=Locale | |
| 143 | ||
| 144 | # ######################################################################## | |
| 145 | # Definition Pane and its Tree View | |
| 146 | # ######################################################################## | |
| 147 | ||
| 148 | Definition.menu.add.default=Undefined | |
| 149 | ||
| 150 | # ######################################################################## | |
| 151 | # Variable Definitions Pane | |
| 152 | # ######################################################################## | |
| 153 | ||
| 154 | Pane.definition.node.root.title=Variables | |
| 155 | ||
| 156 | # ######################################################################## | |
| 157 | # HTML Preview Pane | |
| 158 | # ######################################################################## | |
| 159 | ||
| 160 | Pane.preview.title=Preview | |
| 161 | ||
| 162 | # ######################################################################## | |
| 163 | # Document Outline Pane | |
| 164 | # ######################################################################## | |
| 165 | ||
| 166 | Pane.outline.title=Outline | |
| 167 | ||
| 168 | # ######################################################################## | |
| 169 | # Failure messages with respect to YAML files. | |
| 170 | # ######################################################################## | |
| 171 | ||
| 172 | yaml.error.open=Could not open YAML file (ensure non-empty file). | |
| 173 | yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | |
| 174 | yaml.error.missing=Empty variable value for key ''{0}''. | |
| 175 | yaml.error.tree.form=Unassigned variable near ''{0}''. | |
| 176 | ||
| 177 | # ######################################################################## | |
| 178 | # Text Resource | |
| 179 | # ######################################################################## | |
| 180 | ||
| 181 | TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | |
| 182 | TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | |
| 183 | ||
| 184 | # ######################################################################## | |
| 185 | # Text Resources | |
| 186 | # ######################################################################## | |
| 187 | ||
| 188 | TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | |
| 189 | TextResource.saveFailed.title=Save | |
| 190 | ||
| 191 | # ######################################################################## | |
| 192 | # File Open | |
| 193 | # ######################################################################## | |
| 194 | ||
| 195 | Dialog.file.choose.open.title=Open File | |
| 196 | Dialog.file.choose.save.title=Save File | |
| 197 | Dialog.file.choose.export.title=Export File | |
| 198 | ||
| 199 | Dialog.file.choose.filter.title.source=Source Files | |
| 200 | Dialog.file.choose.filter.title.definition=Variable Files | |
| 201 | Dialog.file.choose.filter.title.xml=XML Files | |
| 202 | Dialog.file.choose.filter.title.all=All Files | |
| 203 | ||
| 204 | # ######################################################################## | |
| 205 | # Browse File | |
| 206 | # ######################################################################## | |
| 207 | ||
| 208 | BrowseFileButton.chooser.title=Browse for local file | |
| 209 | BrowseFileButton.chooser.allFilesFilter=All Files | |
| 210 | BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | |
| 211 | ||
| 212 | # ######################################################################## | |
| 213 | # Alert Dialog | |
| 214 | # ######################################################################## | |
| 215 | ||
| 216 | Alert.file.close.title=Close | |
| 217 | Alert.file.close.text=Save changes to {0}? | |
| 218 | ||
| 219 | # ######################################################################## | |
| 220 | # Image Dialog | |
| 221 | # ######################################################################## | |
| 222 | ||
| 223 | Dialog.image.title=Image | |
| 224 | Dialog.image.chooser.imagesFilter=Images | |
| 225 | Dialog.image.previewLabel.text=Markdown Preview\: | |
| 226 | Dialog.image.textLabel.text=Alternate Text\: | |
| 227 | Dialog.image.titleLabel.text=Title (tooltip)\: | |
| 228 | Dialog.image.urlLabel.text=Image URL\: | |
| 229 | ||
| 230 | # ######################################################################## | |
| 231 | # Hyperlink Dialog | |
| 232 | # ######################################################################## | |
| 233 | ||
| 234 | Dialog.link.title=Link | |
| 235 | Dialog.link.previewLabel.text=Markdown Preview\: | |
| 236 | Dialog.link.textLabel.text=Link Text\: | |
| 237 | Dialog.link.titleLabel.text=Title (tooltip)\: | |
| 238 | Dialog.link.urlLabel.text=Link URL\: | |
| 239 | ||
| 240 | # ######################################################################## | |
| 241 | # About Dialog | |
| 242 | # ######################################################################## | |
| 243 | ||
| 244 | Dialog.about.title=About {0} | |
| 245 | Dialog.about.header={0} | |
| 246 | Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1} | |
| 247 | ||
| 248 | # ######################################################################## | |
| 249 | # Application Actions | |
| 250 | # ######################################################################## | |
| 251 | ||
| 252 | App.action.file.new.description=Create a new file | |
| 253 | App.action.file.new.accelerator=Shortcut+N | |
| 254 | App.action.file.new.icon=FILE_ALT | |
| 255 | App.action.file.new.text=_New | |
| 256 | ||
| 257 | App.action.file.open.description=Open a new file | |
| 258 | App.action.file.open.accelerator=Shortcut+O | |
| 259 | App.action.file.open.text=_Open... | |
| 260 | App.action.file.open.icon=FOLDER_OPEN_ALT | |
| 261 | ||
| 262 | App.action.file.close.description=Close the current document | |
| 263 | App.action.file.close.accelerator=Shortcut+W | |
| 264 | App.action.file.close.text=_Close | |
| 265 | ||
| 266 | App.action.file.close_all.description=Close all open documents | |
| 267 | App.action.file.close_all.accelerator=Ctrl+F4 | |
| 268 | App.action.file.close_all.text=Close All | |
| 269 | ||
| 270 | App.action.file.save.description=Save the document | |
| 271 | App.action.file.save.accelerator=Shortcut+S | |
| 272 | App.action.file.save.text=_Save | |
| 273 | App.action.file.save.icon=FLOPPY_ALT | |
| 274 | ||
| 275 | App.action.file.save_as.description=Rename the current document | |
| 276 | App.action.file.save_as.text=Save _As | |
| 277 | ||
| 278 | App.action.file.save_all.description=Save all open documents | |
| 279 | App.action.file.save_all.accelerator=Shortcut+Shift+S | |
| 280 | App.action.file.save_all.text=Save A_ll | |
| 281 | ||
| 282 | App.action.file.export.html_svg.description=Export the current document as HTML + SVG | |
| 283 | App.action.file.export.text=_Export As | |
| 284 | App.action.file.export.html_svg.text=HTML and S_VG | |
| 285 | ||
| 286 | App.action.file.export.html_tex.description=Export the current document as HTML + TeX | |
| 287 | App.action.file.export.html_tex.text=HTML and _TeX | |
| 288 | ||
| 289 | App.action.file.export.markdown.description=Export the current document as Markdown | |
| 290 | App.action.file.export.markdown.text=Markdown | |
| 291 | ||
| 292 | App.action.file.exit.description=Quit the application | |
| 293 | App.action.file.exit.text=E_xit | |
| 294 | ||
| 295 | ||
| 296 | App.action.edit.undo.description=Undo the previous edit | |
| 297 | App.action.edit.undo.accelerator=Shortcut+Z | |
| 298 | App.action.edit.undo.text=_Undo | |
| 299 | App.action.edit.undo.icon=UNDO | |
| 300 | ||
| 301 | App.action.edit.redo.description=Redo the previous edit | |
| 302 | App.action.edit.redo.accelerator=Shortcut+Y | |
| 303 | App.action.edit.redo.text=_Redo | |
| 304 | App.action.edit.redo.icon=REPEAT | |
| 305 | ||
| 306 | App.action.edit.cut.description=Delete the selected text or line | |
| 307 | App.action.edit.cut.accelerator=Shortcut+X | |
| 308 | App.action.edit.cut.text=Cu_t | |
| 309 | App.action.edit.cut.icon=CUT | |
| 310 | ||
| 311 | App.action.edit.copy.description=Copy the selected text | |
| 312 | App.action.edit.copy.accelerator=Shortcut+C | |
| 313 | App.action.edit.copy.text=_Copy | |
| 314 | App.action.edit.copy.icon=COPY | |
| 315 | ||
| 316 | App.action.edit.paste.description=Paste from the clipboard | |
| 317 | App.action.edit.paste.accelerator=Shortcut+V | |
| 318 | App.action.edit.paste.text=_Paste | |
| 319 | App.action.edit.paste.icon=PASTE | |
| 320 | ||
| 321 | App.action.edit.select_all.description=Highlight the current document text | |
| 322 | App.action.edit.select_all.accelerator=Shortcut+A | |
| 323 | App.action.edit.select_all.text=Select _All | |
| 324 | ||
| 325 | App.action.edit.find.description=Search for text in the document | |
| 326 | App.action.edit.find.accelerator=Shortcut+F | |
| 327 | App.action.edit.find.text=_Find | |
| 328 | App.action.edit.find.icon=SEARCH | |
| 329 | ||
| 330 | App.action.edit.find_next.description=Find next occurrence | |
| 331 | App.action.edit.find_next.accelerator=F3 | |
| 332 | App.action.edit.find_next.text=Find _Next | |
| 333 | ||
| 334 | App.action.edit.find_prev.description=Find previous occurrence | |
| 335 | App.action.edit.find_prev.accelerator=Shift+F3 | |
| 336 | App.action.edit.find_prev.text=Find _Prev | |
| 337 | ||
| 338 | App.action.edit.preferences.description=Edit user preferences | |
| 339 | App.action.edit.preferences.accelerator=Ctrl+Alt+S | |
| 340 | App.action.edit.preferences.text=_Preferences | |
| 341 | ||
| 342 | ||
| 343 | App.action.format.bold.description=Insert strong text | |
| 344 | App.action.format.bold.accelerator=Shortcut+B | |
| 345 | App.action.format.bold.text=_Bold | |
| 346 | App.action.format.bold.icon=BOLD | |
| 347 | ||
| 348 | App.action.format.italic.description=Insert text emphasis | |
| 349 | App.action.format.italic.accelerator=Shortcut+I | |
| 350 | App.action.format.italic.text=_Italic | |
| 351 | App.action.format.italic.icon=ITALIC | |
| 352 | ||
| 353 | App.action.format.superscript.description=Insert superscript text | |
| 354 | App.action.format.superscript.accelerator=Shortcut+[ | |
| 355 | App.action.format.superscript.text=Su_perscript | |
| 356 | App.action.format.superscript.icon=SUPERSCRIPT | |
| 357 | ||
| 358 | App.action.format.subscript.description=Insert subscript text | |
| 359 | App.action.format.subscript.accelerator=Shortcut+] | |
| 360 | App.action.format.subscript.text=Su_bscript | |
| 361 | App.action.format.subscript.icon=SUBSCRIPT | |
| 362 | ||
| 363 | App.action.format.strikethrough.description=Insert struck text | |
| 364 | App.action.format.strikethrough.accelerator=Shortcut+T | |
| 365 | App.action.format.strikethrough.text=Stri_kethrough | |
| 366 | App.action.format.strikethrough.icon=STRIKETHROUGH | |
| 367 | ||
| 368 | ||
| 369 | App.action.insert.blockquote.description=Insert blockquote | |
| 370 | App.action.insert.blockquote.accelerator=Ctrl+Q | |
| 371 | App.action.insert.blockquote.text=_Blockquote | |
| 372 | App.action.insert.blockquote.icon=QUOTE_LEFT | |
| 373 | ||
| 374 | App.action.insert.code.description=Insert inline code | |
| 375 | App.action.insert.code.accelerator=Shortcut+K | |
| 376 | App.action.insert.code.text=Inline _Code | |
| 377 | App.action.insert.code.icon=CODE | |
| 378 | ||
| 379 | App.action.insert.fenced_code_block.description=Insert code block | |
| 380 | App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K | |
| 381 | App.action.insert.fenced_code_block.text=_Fenced Code Block | |
| 382 | App.action.insert.fenced_code_block.prompt.text=Enter code here | |
| 383 | App.action.insert.fenced_code_block.icon=FILE_CODE_ALT | |
| 384 | ||
| 385 | App.action.insert.link.description=Insert hyperlink | |
| 386 | App.action.insert.link.accelerator=Shortcut+L | |
| 387 | App.action.insert.link.text=_Link... | |
| 388 | App.action.insert.link.icon=LINK | |
| 389 | ||
| 390 | App.action.insert.image.description=Insert image | |
| 391 | App.action.insert.image.accelerator=Shortcut+G | |
| 392 | App.action.insert.image.text=_Image... | |
| 393 | App.action.insert.image.icon=PICTURE_ALT | |
| 394 | ||
| 395 | App.action.insert.heading.description=Insert heading level | |
| 396 | App.action.insert.heading.accelerator=Shortcut+ | |
| 397 | App.action.insert.heading.icon=HEADER | |
| 398 | ||
| 399 | App.action.insert.heading_1.description=${App.action.insert.heading.description} 1 | |
| 400 | App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1 | |
| 401 | App.action.insert.heading_1.text=Heading _1 | |
| 402 | App.action.insert.heading_1.icon=${App.action.insert.heading.icon} | |
| 403 | ||
| 404 | App.action.insert.heading_2.description=${App.action.insert.heading.description} 2 | |
| 405 | App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2 | |
| 406 | App.action.insert.heading_2.text=Heading _2 | |
| 407 | App.action.insert.heading_2.icon=${App.action.insert.heading.icon} | |
| 408 | ||
| 409 | App.action.insert.heading_3.description=${App.action.insert.heading.description} 3 | |
| 410 | App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3 | |
| 411 | App.action.insert.heading_3.text=Heading _3 | |
| 412 | App.action.insert.heading_3.icon=${App.action.insert.heading.icon} | |
| 413 | ||
| 414 | App.action.insert.unordered_list.description=Insert bulleted list | |
| 415 | App.action.insert.unordered_list.accelerator=Shortcut+U | |
| 416 | App.action.insert.unordered_list.text=_Unordered List | |
| 417 | App.action.insert.unordered_list.icon=LIST_UL | |
| 418 | ||
| 419 | App.action.insert.ordered_list.description=Insert enumerated list | |
| 420 | App.action.insert.ordered_list.accelerator=Shortcut+Shift+O | |
| 421 | App.action.insert.ordered_list.text=_Ordered List | |
| 422 | App.action.insert.ordered_list.icon=LIST_OL | |
| 423 | ||
| 424 | App.action.insert.horizontal_rule.description=Insert horizontal rule | |
| 425 | App.action.insert.horizontal_rule.accelerator=Shortcut+H | |
| 426 | App.action.insert.horizontal_rule.text=_Horizontal Rule | |
| 427 | App.action.insert.horizontal_rule.icon=LIST_OL | |
| 428 | ||
| 429 | ||
| 430 | App.action.definition.create.description=Create a new variable | |
| 431 | App.action.definition.create.text=_Create | |
| 432 | App.action.definition.create.icon=TREE | |
| 433 | App.action.definition.create.tooltip=Add new item (Insert) | |
| 434 | ||
| 435 | App.action.definition.rename.description=Rename the selected variable | |
| 436 | App.action.definition.rename.text=_Rename | |
| 437 | App.action.definition.rename.icon=EDIT | |
| 438 | App.action.definition.rename.tooltip=Rename selected item (F2) | |
| 439 | ||
| 440 | App.action.definition.delete.description=Delete the selected variables | |
| 441 | App.action.definition.delete.text=De_lete | |
| 442 | App.action.definition.delete.icon=TRASH | |
| 443 | App.action.definition.delete.tooltip=Delete selected items (Delete) | |
| 444 | ||
| 445 | App.action.definition.insert.description=Insert a variable | |
| 434 | 446 | App.action.definition.insert.accelerator=Ctrl+Space |
| 435 | 447 | App.action.definition.insert.text=_Insert |