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