| Author | DaveJarvis <email> |
|---|---|
| Date | 2021-05-24 20:03:02 GMT-0700 |
| Commit | c696142cd3fffee96943b5f59de008620e6e3b68 |
| Parent | 3c45f71 |
| * Support for Pandoc's fenced div extended attribute syntax | ||
| * R integration | ||
| -* XML transformation using XSLT3 or older | ||
| * Customizable user interface having detachable tabs | ||
| * Platform-independent (Windows, Linux, MacOS) |
| * 带变量替换的实时预览 | ||
| * 基于变量值自动完成变量名 | ||
| -* 使用XSLT3或更早版本的XML文档转换 | ||
| * 独立于操作系统 | ||
| * 打字时拼写检查 |
| implementation 'org.yaml:snakeyaml:1.27' | ||
| - // XML and XSL | ||
| + // XML | ||
| implementation 'com.ximpleware:vtd-xml:2.13.4' | ||
| - implementation 'net.sf.saxon:Saxon-HE:10.3' | ||
| // HTML parsing and rendering |
| * Alex Bertram, [Renjin](https://www.renjin.org/) | ||
| * Vladimir Schneider: [flexmark](https://github.com/vsch/flexmark-java) | ||
| -* Michael Kay, [XSLT Processor](http://www.saxonica.com/) | ||
| * Shy Shalom, Kohei Taketa: [juniversalchardet](https://github.com/takscape/juniversalchardet) | ||
| - Mozilla Public License | ||
| - Version 2.0 | ||
| - | ||
| -1. Definitions | ||
| - | ||
| - 1.1. “Contributor” | ||
| - means each individual or legal entity that creates, contributes | ||
| - to the creation of, or owns Covered Software. | ||
| - | ||
| - 1.2. “Contributor Version” | ||
| - means the combination of the Contributions of others (if any) | ||
| - used by a Contributor and that particular Contributor’s | ||
| - Contribution. | ||
| - | ||
| - 1.3. “Contribution” | ||
| - means Covered Software of a particular Contributor. | ||
| - | ||
| - 1.4. “Covered Software” | ||
| - means Source Code Form to which the initial Contributor has | ||
| - attached the notice in Exhibit A, the Executable Form of such | ||
| - Source Code Form, and Modifications of such Source Code Form, in | ||
| - each case including portions thereof. | ||
| - | ||
| - 1.5. “Incompatible With Secondary Licenses” | ||
| - means | ||
| - | ||
| - a. that the initial Contributor has attached the notice described | ||
| - in Exhibit B to the Covered Software; or | ||
| - b. that the Covered Software was made available under the terms | ||
| - of version 1.1 or earlier of the License, but not also under | ||
| - the terms of a Secondary License. | ||
| - | ||
| - 1.6. “Executable Form” | ||
| - means any form of the work other than Source Code Form. | ||
| - | ||
| - 1.7. “Larger Work” | ||
| - means a work that combines Covered Software with other material, | ||
| - in a separate file or files, that is not Covered Software. | ||
| - | ||
| - 1.8. “License” | ||
| - means this document. | ||
| - | ||
| - 1.9. “Licensable” | ||
| - means having the right to grant, to the maximum extent possible, | ||
| - whether at the time of the initial grant or subsequently, any | ||
| - and all of the rights conveyed by this License. | ||
| - | ||
| - 1.10. “Modifications” | ||
| - means any of the following: | ||
| - | ||
| - a. any file in Source Code Form that results from an addition to, | ||
| - deletion from, or modification of the contents of Covered | ||
| - Software; or | ||
| - b. any new file in Source Code Form that contains any Covered | ||
| - Software. | ||
| - | ||
| - 1.11. “Patent Claims” of a Contributor | ||
| - means any patent claim(s), including without limitation, method, | ||
| - process, and apparatus claims, in any patent Licensable by such | ||
| - Contributor that would be infringed, but for the grant of the | ||
| - License, by the making, using, selling, offering for sale, | ||
| - having made, import, or transfer of either its Contributions or | ||
| - its Contributor Version. | ||
| - | ||
| - 1.12. “Secondary License” | ||
| - means either the GNU General Public License, Version 2.0, the | ||
| - GNU Lesser General Public License, Version 2.1, the GNU Affero | ||
| - General Public License, Version 3.0, or any later versions of | ||
| - those licenses. | ||
| - | ||
| - 1.13. “Source Code Form” | ||
| - means the form of the work preferred for making modifications. | ||
| - | ||
| - 1.14. “You” (or “Your”) | ||
| - means an individual or a legal entity exercising rights under | ||
| - this License. For legal entities, “You” includes any entity that | ||
| - controls, is controlled by, or is under common control with You. | ||
| - For purposes of this definition, “control” means (a) the power, | ||
| - direct or indirect, to cause the direction or management of such | ||
| - entity, whether by contract or otherwise, or (b) ownership of | ||
| - more than fifty percent (50%) of the outstanding shares or | ||
| - beneficial ownership of such entity. | ||
| - | ||
| -2. License Grants and Conditions | ||
| - | ||
| - 2.1. Grants | ||
| - | ||
| - Each Contributor hereby grants You a world-wide, royalty-free, | ||
| - non-exclusive license: | ||
| - a. under intellectual property rights (other than patent or trademark) | ||
| - Licensable by such Contributor to use, reproduce, make available, | ||
| - modify, display, perform, distribute, and otherwise exploit its | ||
| - Contributions, either on an unmodified basis, with Modifications, | ||
| - or as part of a Larger Work; and | ||
| - b. under Patent Claims of such Contributor to make, use, sell, offer | ||
| - for sale, have made, import, and otherwise transfer either its | ||
| - Contributions or its Contributor Version. | ||
| - | ||
| - 2.2. Effective Date | ||
| - | ||
| - The licenses granted in Section 2.1 with respect to any Contribution | ||
| - become effective for each Contribution on the date the Contributor | ||
| - first distributes such Contribution. | ||
| - | ||
| - 2.3. Limitations on Grant Scope | ||
| - | ||
| - The licenses granted in this Section 2 are the only rights granted | ||
| - under this License. No additional rights or licenses will be implied | ||
| - from the distribution or licensing of Covered Software under this | ||
| - License. Notwithstanding Section 2.1(b) above, no patent license is | ||
| - granted by a Contributor: | ||
| - a. for any code that a Contributor has removed from Covered Software; | ||
| - or | ||
| - b. for infringements caused by: (i) Your and any other third party’s | ||
| - modifications of Covered Software, or (ii) the combination of its | ||
| - Contributions with other software (except as part of its | ||
| - Contributor Version); or | ||
| - c. under Patent Claims infringed by Covered Software in the absence of | ||
| - its Contributions. | ||
| - | ||
| - This License does not grant any rights in the trademarks, service | ||
| - marks, or logos of any Contributor (except as may be necessary to | ||
| - comply with the notice requirements in Section 3.4). | ||
| - | ||
| - 2.4. Subsequent Licenses | ||
| - | ||
| - No Contributor makes additional grants as a result of Your choice to | ||
| - distribute the Covered Software under a subsequent version of this | ||
| - License (see Section 10.2) or under the terms of a Secondary License | ||
| - (if permitted under the terms of Section 3.3). | ||
| - | ||
| - 2.5. Representation | ||
| - | ||
| - Each Contributor represents that the Contributor believes its | ||
| - Contributions are its original creation(s) or it has sufficient rights | ||
| - to grant the rights to its Contributions conveyed by this License. | ||
| - | ||
| - 2.6. Fair Use | ||
| - | ||
| - This License is not intended to limit any rights You have under | ||
| - applicable copyright doctrines of fair use, fair dealing, or other | ||
| - equivalents. | ||
| - | ||
| - 2.7. Conditions | ||
| - | ||
| - Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted | ||
| - in Section 2.1. | ||
| - | ||
| -3. Responsibilities | ||
| - | ||
| - 3.1. Distribution of Source Form | ||
| - | ||
| - All distribution of Covered Software in Source Code Form, including any | ||
| - Modifications that You create or to which You contribute, must be under | ||
| - the terms of this License. You must inform recipients that the Source | ||
| - Code Form of the Covered Software is governed by the terms of this | ||
| - License, and how they can obtain a copy of this License. You may not | ||
| - attempt to alter or restrict the recipients’ rights in the Source Code | ||
| - Form. | ||
| - | ||
| - 3.2. Distribution of Executable Form | ||
| - | ||
| - If You distribute Covered Software in Executable Form then: | ||
| - a. such Covered Software must also be made available in Source Code | ||
| - Form, as described in Section 3.1, and You must inform recipients | ||
| - of the Executable Form how they can obtain a copy of such Source | ||
| - Code Form by reasonable means in a timely manner, at a charge no | ||
| - more than the cost of distribution to the recipient; and | ||
| - b. You may distribute such Executable Form under the terms of this | ||
| - License, or sublicense it under different terms, provided that the | ||
| - license for the Executable Form does not attempt to limit or alter | ||
| - the recipients’ rights in the Source Code Form under this License. | ||
| - | ||
| - 3.3. Distribution of a Larger Work | ||
| - | ||
| - You may create and distribute a Larger Work under terms of Your choice, | ||
| - provided that You also comply with the requirements of this License for | ||
| - the Covered Software. If the Larger Work is a combination of Covered | ||
| - Software with a work governed by one or more Secondary Licenses, and | ||
| - the Covered Software is not Incompatible With Secondary Licenses, this | ||
| - License permits You to additionally distribute such Covered Software | ||
| - under the terms of such Secondary License(s), so that the recipient of | ||
| - the Larger Work may, at their option, further distribute the Covered | ||
| - Software under the terms of either this License or such Secondary | ||
| - License(s). | ||
| - | ||
| - 3.4. Notices | ||
| - | ||
| - You may not remove or alter the substance of any license notices | ||
| - (including copyright notices, patent notices, disclaimers of warranty, | ||
| - or limitations of liability) contained within the Source Code Form of | ||
| - the Covered Software, except that You may alter any license notices to | ||
| - the extent required to remedy known factual inaccuracies. | ||
| - | ||
| - 3.5. Application of Additional Terms | ||
| - | ||
| - You may choose to offer, and to charge a fee for, warranty, support, | ||
| - indemnity or liability obligations to one or more recipients of Covered | ||
| - Software. However, You may do so only on Your own behalf, and not on | ||
| - behalf of any Contributor. You must make it absolutely clear that any | ||
| - such warranty, support, indemnity, or liability obligation is offered | ||
| - by You alone, and You hereby agree to indemnify every Contributor for | ||
| - any liability incurred by such Contributor as a result of warranty, | ||
| - support, indemnity or liability terms You offer. You may include | ||
| - additional disclaimers of warranty and limitations of liability | ||
| - specific to any jurisdiction. | ||
| - | ||
| -4. Inability to Comply Due to Statute or Regulation | ||
| - | ||
| - If it is impossible for You to comply with any of the terms of this | ||
| - License with respect to some or all of the Covered Software due to | ||
| - statute, judicial order, or regulation then You must: (a) comply with | ||
| - the terms of this License to the maximum extent possible; and (b) | ||
| - describe the limitations and the code they affect. Such description | ||
| - must be placed in a text file included with all distributions of the | ||
| - Covered Software under this License. Except to the extent prohibited by | ||
| - statute or regulation, such description must be sufficiently detailed | ||
| - for a recipient of ordinary skill to be able to understand it. | ||
| - | ||
| -5. Termination | ||
| - | ||
| - 5.1. The rights granted under this License will terminate automatically | ||
| - if You fail to comply with any of its terms. However, if You become | ||
| - compliant, then the rights granted under this License from a particular | ||
| - Contributor are reinstated (a) provisionally, unless and until such | ||
| - Contributor explicitly and finally terminates Your grants, and (b) on | ||
| - an ongoing basis, if such Contributor fails to notify You of the | ||
| - non-compliance by some reasonable means prior to 60 days after You have | ||
| - come back into compliance. Moreover, Your grants from a particular | ||
| - Contributor are reinstated on an ongoing basis if such Contributor | ||
| - notifies You of the non-compliance by some reasonable means, this is | ||
| - the first time You have received notice of non-compliance with this | ||
| - License from such Contributor, and You become compliant prior to 30 | ||
| - days after Your receipt of the notice. | ||
| - | ||
| - 5.2. If You initiate litigation against any entity by asserting a | ||
| - patent infringement claim (excluding declaratory judgment actions, | ||
| - counter-claims, and cross-claims) alleging that a Contributor Version | ||
| - directly or indirectly infringes any patent, then the rights granted to | ||
| - You by any and all Contributors for the Covered Software under | ||
| - Section 2.1 of this License shall terminate. | ||
| - | ||
| - 5.3. In the event of termination under Sections 5.1 or 5.2 above, all | ||
| - end user license agreements (excluding distributors and resellers) | ||
| - which have been validly granted by You or Your distributors under this | ||
| - License prior to termination shall survive termination. | ||
| - | ||
| -6. Disclaimer of Warranty | ||
| - | ||
| - Covered Software is provided under this License on an “as is” basis, | ||
| - without warranty of any kind, either expressed, implied, or statutory, | ||
| - including, without limitation, warranties that the Covered Software is | ||
| - free of defects, merchantable, fit for a particular purpose or | ||
| - non-infringing. The entire risk as to the quality and performance of | ||
| - the Covered Software is with You. Should any Covered Software prove | ||
| - defective in any respect, You (not any Contributor) assume the cost of | ||
| - any necessary servicing, repair, or correction. This disclaimer of | ||
| - warranty constitutes an essential part of this License. No use of any | ||
| - Covered Software is authorized under this License except under this | ||
| - disclaimer. | ||
| - | ||
| -7. Limitation of Liability | ||
| - | ||
| - Under no circumstances and under no legal theory, whether tort | ||
| - (including negligence), contract, or otherwise, shall any Contributor, | ||
| - or anyone who distributes Covered Software as permitted above, be | ||
| - liable to You for any direct, indirect, special, incidental, or | ||
| - consequential damages of any character including, without limitation, | ||
| - damages for lost profits, loss of goodwill, work stoppage, computer | ||
| - failure or malfunction, or any and all other commercial damages or | ||
| - losses, even if such party shall have been informed of the possibility | ||
| - of such damages. This limitation of liability shall not apply to | ||
| - liability for death or personal injury resulting from such party’s | ||
| - negligence to the extent applicable law prohibits such limitation. Some | ||
| - jurisdictions do not allow the exclusion or limitation of incidental or | ||
| - consequential damages, so this exclusion and limitation may not apply | ||
| - to You. | ||
| - | ||
| -8. Litigation | ||
| - | ||
| - Any litigation relating to this License may be brought only in the | ||
| - courts of a jurisdiction where the defendant maintains its principal | ||
| - place of business and such litigation shall be governed by laws of that | ||
| - jurisdiction, without reference to its conflict-of-law provisions. | ||
| - Nothing in this Section shall prevent a party’s ability to bring | ||
| - cross-claims or counter-claims. | ||
| - | ||
| -9. Miscellaneous | ||
| - | ||
| - This License represents the complete agreement concerning the subject | ||
| - matter hereof. If any provision of this License is held to be | ||
| - unenforceable, such provision shall be reformed only to the extent | ||
| - necessary to make it enforceable. Any law or regulation which provides | ||
| - that the language of a contract shall be construed against the drafter | ||
| - shall not be used to construe this License against a Contributor. | ||
| - | ||
| -10. Versions of the License | ||
| - | ||
| - 10.1. New Versions | ||
| - | ||
| - Mozilla Foundation is the license steward. Except as provided in | ||
| - Section 10.3, no one other than the license steward has the right to | ||
| - modify or publish new versions of this License. Each version will be | ||
| - given a distinguishing version number. | ||
| - | ||
| - 10.2. Effect of New Versions | ||
| - | ||
| - You may distribute the Covered Software under the terms of the version | ||
| - of the License under which You originally received the Covered | ||
| - Software, or under the terms of any subsequent version published by the | ||
| - license steward. | ||
| - | ||
| - 10.3. Modified Versions | ||
| - | ||
| - If you create software not governed by this License, and you want to | ||
| - create a new license for such software, you may create and use a | ||
| - modified version of this License if you rename the license and remove | ||
| - any references to the name of the license steward (except to note that | ||
| - such modified license differs from this License). | ||
| - | ||
| - 10.4. Distributing Source Code Form that is Incompatible With Secondary | ||
| - Licenses | ||
| - | ||
| - If You choose to distribute Source Code Form that is Incompatible With | ||
| - Secondary Licenses under the terms of this version of the License, the | ||
| - notice described in Exhibit B of this License must be attached. | ||
| - | ||
| -Exhibit A - Source Code Form License Notice | ||
| - | ||
| - This Source Code Form is subject to the terms of the Mozilla Public | ||
| - License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| - file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
| - | ||
| - If it is not possible or desirable to put the notice in a particular | ||
| - file, then You may include the notice in a location (such as a LICENSE | ||
| - file in a relevant directory) where a recipient would be likely to look | ||
| - for such a notice. | ||
| - | ||
| - You may add additional accurate notices of copyright ownership. | ||
| - | ||
| -Exhibit B - “Incompatible With Secondary Licenses” Notice | ||
| - | ||
| - This Source Code Form is “Incompatible With Secondary Licenses”, as | ||
| - defined by the Mozilla Public License, v. 2.0. | ||
| * called when it is known that the file type won't be a definition file | ||
| * (e.g., YAML or other definition source), but rather an editable file | ||
| - * (e.g., Markdown, XML, etc.). | ||
| + * (e.g., Markdown, R Markdown, etc.). | ||
| * | ||
| * @param path The path with a file name extension. |
| */ | ||
| private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | ||
| - TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED | ||
| - ); | ||
| - | ||
| - /** | ||
| - * Prevents re-instantiation of processing classes. | ||
| - */ | ||
| - private final Map<TextResource, Processor<String>> mProcessors = | ||
| - new HashMap<>(); | ||
| - | ||
| - private final Workspace mWorkspace; | ||
| - | ||
| - /** | ||
| - * Groups similar file type tabs together. | ||
| - */ | ||
| - private final List<TabPane> mTabPanes = new ArrayList<>(); | ||
| - | ||
| - /** | ||
| - * Stores definition names and values. | ||
| - */ | ||
| - private final Map<String, String> mResolvedMap = | ||
| - new HashMap<>( MAP_SIZE_DEFAULT ); | ||
| - | ||
| - /** | ||
| - * Renders the actively selected plain text editor tab. | ||
| - */ | ||
| - private final HtmlPreview mPreview; | ||
| - | ||
| - /** | ||
| - * Provides an interactive document outline. | ||
| - */ | ||
| - private final DocumentOutline mOutline = new DocumentOutline(); | ||
| - | ||
| - /** | ||
| - * Changing the active editor fires the value changed event. This allows | ||
| - * refreshes to happen when external definitions are modified and need to | ||
| - * trigger the processing chain. | ||
| - */ | ||
| - private final ObjectProperty<TextEditor> mActiveTextEditor = | ||
| - createActiveTextEditor(); | ||
| - | ||
| - /** | ||
| - * Changing the active definition editor fires the value changed event. This | ||
| - * allows refreshes to happen when external definitions are modified and need | ||
| - * to trigger the processing chain. | ||
| - */ | ||
| - private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | ||
| - createActiveDefinitionEditor( mActiveTextEditor ); | ||
| - | ||
| - /** | ||
| - * Tracks the number of detached tab panels opened into their own windows, | ||
| - * which allows unique identification of subordinate windows by their title. | ||
| - * It is doubtful more than 128 windows, much less 256, will be created. | ||
| - */ | ||
| - private byte mWindowCount; | ||
| - | ||
| - /** | ||
| - * Called when the definition data is changed. | ||
| - */ | ||
| - private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | ||
| - event -> { | ||
| - final var editor = mActiveDefinitionEditor.get(); | ||
| - | ||
| - resolve( editor ); | ||
| - process( getActiveTextEditor() ); | ||
| - save( editor ); | ||
| - }; | ||
| - | ||
| - private final DocumentStatistics mStatistics; | ||
| - | ||
| - /** | ||
| - * Adds all content panels to the main user interface. This will load the | ||
| - * configuration settings from the workspace to reproduce the settings from | ||
| - * a previous session. | ||
| - */ | ||
| - public MainPane( final Workspace workspace ) { | ||
| - mWorkspace = workspace; | ||
| - mPreview = new HtmlPreview( workspace ); | ||
| - mStatistics = new DocumentStatistics( workspace ); | ||
| - mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | ||
| - | ||
| - open( bin( getRecentFiles() ) ); | ||
| - viewPreview(); | ||
| - setDividerPositions( calculateDividerPositions() ); | ||
| - | ||
| - // Once the main scene's window regains focus, update the active definition | ||
| - // editor to the currently selected tab. | ||
| - runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | ||
| - // Order matters here. We want to close all the tabs to ensure each | ||
| - // is saved, but after they are closed, the workspace should still | ||
| - // retain the list of files that were open. If this line came after | ||
| - // closing, then restarting the application would list no files. | ||
| - mWorkspace.save(); | ||
| - | ||
| - if( closeAll() ) { | ||
| - Platform.exit(); | ||
| - System.exit( 0 ); | ||
| - } | ||
| - else { | ||
| - event.consume(); | ||
| - } | ||
| - } ) ); | ||
| - | ||
| - register( this ); | ||
| - } | ||
| - | ||
| - @Subscribe | ||
| - public void handle( final TextEditorFocusEvent event ) { | ||
| - mActiveTextEditor.set( event.get() ); | ||
| - } | ||
| - | ||
| - @Subscribe | ||
| - public void handle( final TextDefinitionFocusEvent event ) { | ||
| - mActiveDefinitionEditor.set( event.get() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Typically called when a file name is clicked in the {@link HtmlPanel}. | ||
| - * | ||
| - * @param event The event to process, must contain a valid file reference. | ||
| - */ | ||
| - @Subscribe | ||
| - public void handle( final FileOpenEvent event ) { | ||
| - final File eventFile; | ||
| - final var eventUri = event.getUri(); | ||
| - | ||
| - if( eventUri.isAbsolute() ) { | ||
| - eventFile = new File( eventUri.getPath() ); | ||
| - } | ||
| - else { | ||
| - final var activeFile = getActiveTextEditor().getFile(); | ||
| - final var parent = activeFile.getParentFile(); | ||
| - | ||
| - if( parent == null ) { | ||
| - clue( new FileNotFoundException( eventUri.getPath() ) ); | ||
| - return; | ||
| - } | ||
| - else { | ||
| - final var parentPath = parent.getAbsolutePath(); | ||
| - eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | ||
| - } | ||
| - } | ||
| - | ||
| - runLater( () -> open( eventFile ) ); | ||
| - } | ||
| - | ||
| - @Subscribe | ||
| - public void handle( final CaretNavigationEvent event ) { | ||
| - runLater( () -> { | ||
| - final var textArea = getActiveTextEditor().getTextArea(); | ||
| - textArea.moveTo( event.getOffset() ); | ||
| - textArea.requestFollowCaret(); | ||
| - textArea.requestFocus(); | ||
| - } ); | ||
| - } | ||
| - | ||
| - @Subscribe | ||
| - @SuppressWarnings( "unused" ) | ||
| - public void handle( final ExportFailedEvent event ) { | ||
| - final var os = getProperty( "os.name" ); | ||
| - final var arch = getProperty( "os.arch" ).toLowerCase(); | ||
| - final var bits = getProperty( "sun.arch.data.model" ); | ||
| - | ||
| - final var title = Messages.get( "Alert.typesetter.missing.title" ); | ||
| - final var header = Messages.get( "Alert.typesetter.missing.header" ); | ||
| - final var version = Messages.get( | ||
| - "Alert.typesetter.missing.version", | ||
| - os, | ||
| - arch | ||
| - .replaceAll( "amd.*|i.*|x86.*", "X86" ) | ||
| - .replaceAll( "mips.*", "MIPS" ) | ||
| - .replaceAll( "armv.*", "ARM" ), | ||
| - bits ); | ||
| - final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | ||
| - | ||
| - // Download and install ConTeXt for {0} {1} {2}-bit | ||
| - final var content = format( "%s %s", text, version ); | ||
| - final var flowPane = new FlowPane(); | ||
| - final var link = new Hyperlink( text ); | ||
| - final var label = new Label( version ); | ||
| - flowPane.getChildren().addAll( link, label ); | ||
| - | ||
| - final var alert = new Alert( ERROR, content, OK ); | ||
| - alert.setTitle( title ); | ||
| - alert.setHeaderText( header ); | ||
| - alert.getDialogPane().contentProperty().set( flowPane ); | ||
| - alert.setGraphic( ICON_DIALOG_NODE ); | ||
| - | ||
| - link.setOnAction( ( e ) -> { | ||
| - alert.close(); | ||
| - final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | ||
| - runLater( () -> fireHyperlinkOpenEvent( url ) ); | ||
| - } ); | ||
| - | ||
| - alert.showAndWait(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * TODO: Load divider positions from exported settings, see bin() comment. | ||
| - */ | ||
| - private double[] calculateDividerPositions() { | ||
| - final var ratio = 100f / getItems().size() / 100; | ||
| - final var positions = getDividerPositions(); | ||
| - | ||
| - for( int i = 0; i < positions.length; i++ ) { | ||
| - positions[ i ] = ratio * i; | ||
| - } | ||
| - | ||
| - return positions; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Opens all the files into the application, provided the paths are unique. | ||
| - * This may only be called for any type of files that a user can edit | ||
| - * (i.e., update and persist), such as definitions and text files. | ||
| - * | ||
| - * @param files The list of files to open. | ||
| - */ | ||
| - public void open( final List<File> files ) { | ||
| - files.forEach( this::open ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * This opens the given file. Since the preview pane is not a file that | ||
| - * can be opened, it is safe to add a listener to the detachable pane. | ||
| - * | ||
| - * @param file The file to open. | ||
| - */ | ||
| - private void open( final File file ) { | ||
| - final var tab = createTab( file ); | ||
| - final var node = tab.getContent(); | ||
| - final var mediaType = MediaType.valueFrom( file ); | ||
| - final var tabPane = obtainTabPane( mediaType ); | ||
| - | ||
| - tab.setTooltip( createTooltip( file ) ); | ||
| - tabPane.setFocusTraversable( false ); | ||
| - tabPane.setTabClosingPolicy( ALL_TABS ); | ||
| - tabPane.getTabs().add( tab ); | ||
| - | ||
| - // Attach the tab scene factory for new tab panes. | ||
| - if( !getItems().contains( tabPane ) ) { | ||
| - addTabPane( | ||
| - node instanceof TextDefinition ? 0 : getItems().size(), tabPane | ||
| - ); | ||
| - } | ||
| - | ||
| - getRecentFiles().add( file.getAbsolutePath() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Opens a new text editor document using the default document file name. | ||
| - */ | ||
| - public void newTextEditor() { | ||
| - open( DOCUMENT_DEFAULT ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Opens a new definition editor document using the default definition | ||
| - * file name. | ||
| - */ | ||
| - public void newDefinitionEditor() { | ||
| - open( DEFINITION_DEFAULT ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Iterates over all tab panes to find all {@link TextEditor}s and request | ||
| - * that they save themselves. | ||
| - */ | ||
| - public void saveAll() { | ||
| - mTabPanes.forEach( | ||
| - ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | ||
| - final var node = tab.getContent(); | ||
| - if( node instanceof final TextEditor editor ) { | ||
| - save( editor ); | ||
| - } | ||
| - } ) | ||
| - ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Requests that the active {@link TextEditor} saves itself. Don't bother | ||
| - * checking if modified first because if the user swaps external media from | ||
| - * an external source (e.g., USB thumb drive), save should not second-guess | ||
| - * the user: save always re-saves. Also, it's less code. | ||
| - */ | ||
| - public void save() { | ||
| - save( getActiveTextEditor() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Saves the active {@link TextEditor} under a new name. | ||
| - * | ||
| - * @param files The new active editor {@link File} reference, must contain | ||
| - * at least one element. | ||
| - */ | ||
| - public void saveAs( final List<File> files ) { | ||
| - assert files != null; | ||
| - assert !files.isEmpty(); | ||
| - final var editor = getActiveTextEditor(); | ||
| - final var tab = getTab( editor ); | ||
| - final var file = files.get( 0 ); | ||
| - | ||
| - editor.rename( file ); | ||
| - tab.ifPresent( t -> { | ||
| - t.setText( editor.getFilename() ); | ||
| - t.setTooltip( createTooltip( file ) ); | ||
| - } ); | ||
| - | ||
| - save(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Saves the given {@link TextResource} to a file. This is typically used | ||
| - * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | ||
| - * | ||
| - * @param resource The resource to export. | ||
| - */ | ||
| - private void save( final TextResource resource ) { | ||
| - try { | ||
| - resource.save(); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - sNotifier.alert( | ||
| - getWindow(), resource.getPath(), "TextResource.saveFailed", ex | ||
| - ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | ||
| - * | ||
| - * @return {@code true} when all editors, modified or otherwise, were | ||
| - * permitted to close; {@code false} when one or more editors were modified | ||
| - * and the user requested no closing. | ||
| - */ | ||
| - public boolean closeAll() { | ||
| - var closable = true; | ||
| - | ||
| - for( final var tabPane : mTabPanes ) { | ||
| - final var tabIterator = tabPane.getTabs().iterator(); | ||
| - | ||
| - while( tabIterator.hasNext() ) { | ||
| - final var tab = tabIterator.next(); | ||
| - final var resource = tab.getContent(); | ||
| - | ||
| - // The definition panes auto-save, so being specific here prevents | ||
| - // closing the definitions in the situation where the user wants to | ||
| - // continue editing (i.e., possibly save unsaved work). | ||
| - if( !(resource instanceof TextEditor) ) { | ||
| - continue; | ||
| - } | ||
| - | ||
| - if( canClose( (TextEditor) resource ) ) { | ||
| - tabIterator.remove(); | ||
| - close( tab ); | ||
| - } | ||
| - else { | ||
| - closable = false; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return closable; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | ||
| - * event. | ||
| - * | ||
| - * @param tab The {@link Tab} that was closed. | ||
| - */ | ||
| - private void close( final Tab tab ) { | ||
| - final var handler = tab.getOnClosed(); | ||
| - | ||
| - if( handler != null ) { | ||
| - handler.handle( new ActionEvent() ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Closes the active tab; delegates to {@link #canClose(TextResource)}. | ||
| - */ | ||
| - public void close() { | ||
| - final var editor = getActiveTextEditor(); | ||
| - | ||
| - if( canClose( editor ) ) { | ||
| - close( editor ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Closes the given {@link TextResource}. This must not be called from within | ||
| - * a loop that iterates over the tab panes using {@code forEach}, lest a | ||
| - * concurrent modification exception be thrown. | ||
| - * | ||
| - * @param resource The {@link TextResource} to close, without confirming with | ||
| - * the user. | ||
| - */ | ||
| - private void close( final TextResource resource ) { | ||
| - getTab( resource ).ifPresent( | ||
| - ( tab ) -> { | ||
| - tab.getTabPane().getTabs().remove( tab ); | ||
| - close( tab ); | ||
| - } | ||
| - ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Answers whether the given {@link TextResource} may be closed. | ||
| - * | ||
| - * @param editor The {@link TextResource} to try closing. | ||
| - * @return {@code true} when the editor may be closed; {@code false} when | ||
| - * the user has requested to keep the editor open. | ||
| - */ | ||
| - private boolean canClose( final TextResource editor ) { | ||
| - final var editorTab = getTab( editor ); | ||
| - final var canClose = new AtomicBoolean( true ); | ||
| - | ||
| - if( editor.isModified() ) { | ||
| - final var filename = new StringBuilder(); | ||
| - editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | ||
| - | ||
| - final var message = sNotifier.createNotification( | ||
| - Messages.get( "Alert.file.close.title" ), | ||
| - Messages.get( "Alert.file.close.text" ), | ||
| - filename.toString() | ||
| - ); | ||
| - | ||
| - final var dialog = sNotifier.createConfirmation( getWindow(), message ); | ||
| - | ||
| - dialog.showAndWait().ifPresent( | ||
| - save -> canClose.set( save == YES ? editor.save() : save == NO ) | ||
| - ); | ||
| - } | ||
| - | ||
| - return canClose.get(); | ||
| - } | ||
| - | ||
| - private ObjectProperty<TextEditor> createActiveTextEditor() { | ||
| - final var editor = new SimpleObjectProperty<TextEditor>(); | ||
| - | ||
| - editor.addListener( ( c, o, n ) -> { | ||
| - if( n != null ) { | ||
| - mPreview.setBaseUri( n.getPath() ); | ||
| - process( n ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - return editor; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Adds the HTML preview tab to its own, singular tab pane. | ||
| - */ | ||
| - public void viewPreview() { | ||
| - viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Adds the document outline tab to its own, singular tab pane. | ||
| - */ | ||
| - public void viewOutline() { | ||
| - viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | ||
| - } | ||
| - | ||
| - public void viewStatistics() { | ||
| - viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | ||
| - } | ||
| - | ||
| - public void viewFiles() { | ||
| - try { | ||
| - final var factory = new FilePickerFactory( mWorkspace ); | ||
| - final var fileManager = factory.createModeless(); | ||
| - viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - } | ||
| - | ||
| - private void viewTab( | ||
| - final Node node, final MediaType mediaType, final String key ) { | ||
| - final var tabPane = obtainTabPane( mediaType ); | ||
| - | ||
| - for( final var tab : tabPane.getTabs() ) { | ||
| - if( tab.getContent() == node ) { | ||
| - return; | ||
| - } | ||
| - } | ||
| - | ||
| - tabPane.getTabs().add( createTab( get( key ), node ) ); | ||
| - addTabPane( tabPane ); | ||
| - } | ||
| - | ||
| - public void viewRefresh() { | ||
| - mPreview.refresh(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the tab that contains the given {@link TextEditor}. | ||
| - * | ||
| - * @param editor The {@link TextEditor} instance to find amongst the tabs. | ||
| - * @return The first tab having content that matches the given tab. | ||
| - */ | ||
| - private Optional<Tab> getTab( final TextResource editor ) { | ||
| - return mTabPanes.stream() | ||
| - .flatMap( pane -> pane.getTabs().stream() ) | ||
| - .filter( tab -> editor.equals( tab.getContent() ) ) | ||
| - .findFirst(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a new {@link DefinitionEditor} wrapped in a listener that | ||
| - * is used to detect when the active {@link DefinitionEditor} has changed. | ||
| - * Upon changing, the {@link #mResolvedMap} is updated and the active | ||
| - * text editor is refreshed. | ||
| - * | ||
| - * @param editor Text editor to update with the revised resolved map. | ||
| - * @return A newly configured property that represents the active | ||
| - * {@link DefinitionEditor}, never null. | ||
| - */ | ||
| - private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | ||
| - final ObjectProperty<TextEditor> editor ) { | ||
| - final var definitions = new SimpleObjectProperty<TextDefinition>(); | ||
| - definitions.addListener( ( c, o, n ) -> { | ||
| - resolve( n == null ? createDefinitionEditor() : n ); | ||
| - process( editor.get() ); | ||
| - } ); | ||
| - | ||
| - return definitions; | ||
| - } | ||
| - | ||
| - private Tab createTab( final String filename, final Node node ) { | ||
| - return new DetachableTab( filename, node ); | ||
| - } | ||
| - | ||
| - private Tab createTab( final File file ) { | ||
| - final var r = createTextResource( file ); | ||
| - final var tab = createTab( r.getFilename(), r.getNode() ); | ||
| - | ||
| - r.modifiedProperty().addListener( | ||
| - ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | ||
| - ); | ||
| - | ||
| - // This is called when either the tab is closed by the user clicking on | ||
| - // the tab's close icon or when closing (all) from the file menu. | ||
| - tab.setOnClosed( | ||
| - ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | ||
| - ); | ||
| - | ||
| - tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | ||
| - if( nPane != null ) { | ||
| - nPane.focusedProperty().addListener( ( c, o, n ) -> { | ||
| - if( n != null && n ) { | ||
| - final var selected = nPane.getSelectionModel().getSelectedItem(); | ||
| - final var node = selected.getContent(); | ||
| - node.requestFocus(); | ||
| - } | ||
| - } ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - return tab; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates bins for the different {@link MediaType}s, which eventually are | ||
| - * added to the UI as separate tab panes. If ever a general-purpose scene | ||
| - * exporter is developed to serialize a scene to an FXML file, this could | ||
| - * be replaced by such a class. | ||
| - * <p> | ||
| - * When binning the files, this makes sure that at least one file exists | ||
| - * for every type. If the user has opted to close a particular type (such | ||
| - * as the definition pane), the view will suppressed elsewhere. | ||
| - * </p> | ||
| - * <p> | ||
| - * The order that the binned files are returned will be reflected in the | ||
| - * order that the corresponding panes are rendered in the UI. | ||
| - * </p> | ||
| - * | ||
| - * @param paths The file paths to bin according to their type. | ||
| - * @return An in-order list of files, first by structured definition files, | ||
| - * then by plain text documents. | ||
| - */ | ||
| - private List<File> bin( final SetProperty<String> paths ) { | ||
| - // Treat all files destined for the text editor as plain text documents | ||
| - // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | ||
| - // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | ||
| - final Function<MediaType, MediaType> bin = | ||
| - m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | ||
| - | ||
| - // Create two groups: YAML files and plain text files. | ||
| - final var bins = paths | ||
| - .stream() | ||
| - .collect( | ||
| - groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) ) | ||
| - ); | ||
| - | ||
| - bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | ||
| - bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | ||
| - | ||
| - final var result = new ArrayList<File>( paths.size() ); | ||
| - | ||
| - // Ensure that the same types are listed together (keep insertion order). | ||
| - bins.forEach( ( mediaType, files ) -> result.addAll( | ||
| - files.stream().map( File::new ).collect( Collectors.toList() ) ) | ||
| - ); | ||
| - | ||
| - return result; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Uses the given {@link TextDefinition} instance to update the | ||
| - * {@link #mResolvedMap}. | ||
| - * | ||
| - * @param editor A non-null, possibly empty definition editor. | ||
| - */ | ||
| - private void resolve( final TextDefinition editor ) { | ||
| - assert editor != null; | ||
| - | ||
| - final var tokens = createDefinitionTokens(); | ||
| - final var operator = new YamlSigilOperator( tokens ); | ||
| - final var map = new HashMap<String, String>(); | ||
| - | ||
| - editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | ||
| - | ||
| - mResolvedMap.clear(); | ||
| - mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Force the active editor to update, which will cause the processor | ||
| - * to re-evaluate the interpolated definition map thereby updating the | ||
| - * preview pane. | ||
| - * | ||
| - * @param editor Contains the source document to update in the preview pane. | ||
| - */ | ||
| - private void process( final TextEditor editor ) { | ||
| - // Ensure processing does not run on the JavaFX thread, which frees the | ||
| - // text editor immediately for caret movement. The preview will have a | ||
| - // slight delay when catching up to the caret position. | ||
| - final var task = new Task<Void>() { | ||
| - @Override | ||
| - public Void call() { | ||
| - try { | ||
| - final var p = mProcessors.getOrDefault( editor, IDENTITY ); | ||
| - p.apply( editor == null ? "" : editor.getText() ); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - | ||
| - return null; | ||
| - } | ||
| - }; | ||
| - | ||
| - task.setOnSucceeded( | ||
| - e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | ||
| - ); | ||
| - | ||
| - // Prevents multiple process requests from executing simultaneously (due | ||
| - // to having a restricted queue size). | ||
| - sExecutor.execute( task ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Lazily creates a {@link TabPane} configured to listen for tab select | ||
| - * events. The tab pane is associated with a given media type so that | ||
| - * similar files can be grouped together. | ||
| - * | ||
| - * @param mediaType The media type to associate with the tab pane. | ||
| - * @return An instance of {@link TabPane} that will handle tab docking. | ||
| - */ | ||
| - private TabPane obtainTabPane( final MediaType mediaType ) { | ||
| - for( final var pane : mTabPanes ) { | ||
| - for( final var tab : pane.getTabs() ) { | ||
| - final var node = tab.getContent(); | ||
| - | ||
| - if( node instanceof TextResource r && r.supports( mediaType ) ) { | ||
| - return pane; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - final var pane = createTabPane(); | ||
| - mTabPanes.add( pane ); | ||
| - return pane; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates an initialized {@link TabPane} instance. | ||
| - * | ||
| - * @return A new {@link TabPane} with all listeners configured. | ||
| - */ | ||
| - private TabPane createTabPane() { | ||
| - final var tabPane = new DetachableTabPane(); | ||
| - | ||
| - initStageOwnerFactory( tabPane ); | ||
| - initTabListener( tabPane ); | ||
| - | ||
| - return tabPane; | ||
| - } | ||
| - | ||
| - /** | ||
| - * When any {@link DetachableTabPane} is detached from the main window, | ||
| - * the stage owner factory must be given its parent window, which will | ||
| - * own the child window. The parent window is the {@link MainPane}'s | ||
| - * {@link Scene}'s {@link Window} instance. | ||
| - * | ||
| - * <p> | ||
| - * This will derives the new title from the main window title, incrementing | ||
| - * the window count to help uniquely identify the child windows. | ||
| - * </p> | ||
| - * | ||
| - * @param tabPane A new {@link DetachableTabPane} to configure. | ||
| - */ | ||
| - private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | ||
| - tabPane.setStageOwnerFactory( ( stage ) -> { | ||
| - final var title = get( | ||
| - "Detach.tab.title", | ||
| - ((Stage) getWindow()).getTitle(), ++mWindowCount | ||
| - ); | ||
| - stage.setTitle( title ); | ||
| - | ||
| - return getScene().getWindow(); | ||
| - } ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Responsible for configuring the content of each {@link DetachableTab} when | ||
| - * it is added to the given {@link DetachableTabPane} instance. | ||
| - * <p> | ||
| - * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | ||
| - * is initialized to perform synchronized scrolling between the editor and | ||
| - * its preview window. Additionally, the last tab in the tab pane's list of | ||
| - * tabs is given focus. | ||
| - * </p> | ||
| - * <p> | ||
| - * Note that multiple tabs can be added simultaneously. | ||
| - * </p> | ||
| - * | ||
| - * @param tabPane A new {@link TabPane} to configure. | ||
| - */ | ||
| - private void initTabListener( final TabPane tabPane ) { | ||
| - tabPane.getTabs().addListener( | ||
| - ( final ListChangeListener.Change<? extends Tab> listener ) -> { | ||
| - while( listener.next() ) { | ||
| - if( listener.wasAdded() ) { | ||
| - final var tabs = listener.getAddedSubList(); | ||
| - | ||
| - tabs.forEach( ( tab ) -> { | ||
| - final var node = tab.getContent(); | ||
| - | ||
| - if( node instanceof TextEditor ) { | ||
| - initScrollEventListener( tab ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - // Select and give focus to the last tab opened. | ||
| - final var index = tabs.size() - 1; | ||
| - if( index >= 0 ) { | ||
| - final var tab = tabs.get( index ); | ||
| - tabPane.getSelectionModel().select( tab ); | ||
| - tab.getContent().requestFocus(); | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Synchronizes scrollbar positions between the given {@link Tab} that | ||
| - * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | ||
| - * | ||
| - * @param tab The container for an instance of {@link TextEditor}. | ||
| - */ | ||
| - private void initScrollEventListener( final Tab tab ) { | ||
| - final var editor = (TextEditor) tab.getContent(); | ||
| - final var scrollPane = editor.getScrollPane(); | ||
| - final var scrollBar = mPreview.getVerticalScrollBar(); | ||
| - final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | ||
| - handler.enabledProperty().bind( tab.selectedProperty() ); | ||
| - } | ||
| - | ||
| - private void addTabPane( final int index, final TabPane tabPane ) { | ||
| - final var items = getItems(); | ||
| - if( !items.contains( tabPane ) ) { | ||
| - items.add( index, tabPane ); | ||
| - } | ||
| - } | ||
| - | ||
| - private void addTabPane( final TabPane tabPane ) { | ||
| - addTabPane( getItems().size(), tabPane ); | ||
| - } | ||
| - | ||
| - public ProcessorContext createProcessorContext() { | ||
| - return createProcessorContext( null, NONE ); | ||
| - } | ||
| - | ||
| - public ProcessorContext createProcessorContext( | ||
| - final Path exportPath, final ExportFormat format ) { | ||
| - final var editor = getActiveTextEditor(); | ||
| - return createProcessorContext( | ||
| - editor.getPath(), exportPath, format, editor.getCaret() ); | ||
| - } | ||
| - | ||
| - private ProcessorContext createProcessorContext( | ||
| - final Path path, final Caret caret ) { | ||
| - return createProcessorContext( path, null, ExportFormat.NONE, caret ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * @param path Used by {@link ProcessorFactory} to determine | ||
| - * {@link Processor} type to create based on file type. | ||
| - * @param exportPath Used when exporting to a PDF file (binary). | ||
| - * @param format Used when processors export to a new text format. | ||
| - * @param caret Used by {@link CaretExtension} to add ID attribute into | ||
| - * preview document for scrollbar synchronization. | ||
| - * @return A new {@link ProcessorContext} to use when creating an instance of | ||
| - * {@link Processor}. | ||
| - */ | ||
| - private ProcessorContext createProcessorContext( | ||
| - final Path path, final Path exportPath, final ExportFormat format, | ||
| - final Caret caret ) { | ||
| - return new ProcessorContext( | ||
| - mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret | ||
| - ); | ||
| - } | ||
| - | ||
| - private TextResource createTextResource( final File file ) { | ||
| - // TODO: Create PlainTextEditor that's returned by default. | ||
| - return MediaType.valueFrom( file ) == TEXT_YAML | ||
| - ? createDefinitionEditor( file ) | ||
| - : createMarkdownEditor( file ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates an instance of {@link MarkdownEditor} that listens for both | ||
| - * caret change events and text change events. Text change events must | ||
| - * take priority over caret change events because it's possible to change | ||
| - * the text without moving the caret (e.g., delete selected text). | ||
| - * | ||
| - * @param file The file containing contents for the text editor. | ||
| - * @return A non-null text editor. | ||
| - */ | ||
| - private TextResource createMarkdownEditor( final File file ) { | ||
| - final var path = file.toPath(); | ||
| - final var editor = new MarkdownEditor( file, getWorkspace() ); | ||
| - final var caret = editor.getCaret(); | ||
| - final var context = createProcessorContext( path, caret ); | ||
| - | ||
| - mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | ||
| - | ||
| - editor.addDirtyListener( ( c, o, n ) -> { | ||
| - if( n ) { | ||
| - // Reset the status to OK after changing the text. | ||
| - clue(); | ||
| - | ||
| - // Processing the text may update the status bar. | ||
| - process( getActiveTextEditor() ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - editor.addEventListener( | ||
| - keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | ||
| - ); | ||
| - | ||
| - // Set the active editor, which refreshes the preview panel. | ||
| - mActiveTextEditor.set( editor ); | ||
| - | ||
| - return editor; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Delegates to {@link #autoinsert()}. | ||
| - * | ||
| - * @param event Ignored. | ||
| - */ | ||
| - private void autoinsert( final KeyEvent event ) { | ||
| - autoinsert(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Finds a node that matches the word at the caret, then inserts the | ||
| - * corresponding definition. The definition token delimiters depend on | ||
| - * the type of file being edited. | ||
| - */ | ||
| - public void autoinsert() { | ||
| - final var definitions = getActiveTextDefinition(); | ||
| - final var editor = getActiveTextEditor(); | ||
| - final var mediaType = editor.getMediaType(); | ||
| - final var operator = getSigilOperator( mediaType ); | ||
| - | ||
| - DefinitionNameInjector.autoinsert( editor, definitions, operator ); | ||
| - } | ||
| - | ||
| - private TextDefinition createDefinitionEditor() { | ||
| - return createDefinitionEditor( DEFINITION_DEFAULT ); | ||
| - } | ||
| - | ||
| - private TextDefinition createDefinitionEditor( final File file ) { | ||
| - final var editor = new DefinitionEditor( file, createTreeTransformer() ); | ||
| - editor.addTreeChangeHandler( mTreeHandler ); | ||
| - return editor; | ||
| - } | ||
| - | ||
| - private TreeTransformer createTreeTransformer() { | ||
| - return new YamlTreeTransformer(); | ||
| - } | ||
| - | ||
| - private Tooltip createTooltip( final File file ) { | ||
| - final var path = file.toPath(); | ||
| - final var tooltip = new Tooltip( path.toString() ); | ||
| - | ||
| - tooltip.setShowDelay( millis( 200 ) ); | ||
| - return tooltip; | ||
| - } | ||
| - | ||
| - public TextEditor getActiveTextEditor() { | ||
| - return mActiveTextEditor.get(); | ||
| - } | ||
| - | ||
| - public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() { | ||
| - return mActiveTextEditor; | ||
| - } | ||
| - | ||
| - public TextDefinition getActiveTextDefinition() { | ||
| - return mActiveDefinitionEditor.get(); | ||
| - } | ||
| - | ||
| - public Window getWindow() { | ||
| - return getScene().getWindow(); | ||
| - } | ||
| - | ||
| - public Workspace getWorkspace() { | ||
| - return mWorkspace; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the sigil operator for the given {@link MediaType}. | ||
| - * | ||
| - * @param mediaType The type of file being edited. | ||
| - */ | ||
| - private SigilOperator getSigilOperator( final MediaType mediaType ) { | ||
| - final var operator = new YamlSigilOperator( createDefinitionTokens() ); | ||
| - | ||
| - return switch( mediaType ) { | ||
| - case TEXT_R_MARKDOWN, TEXT_R_XML -> new RSigilOperator( | ||
| - createRTokens(), operator ); | ||
| - default -> operator; | ||
| - }; | ||
| + TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | ||
| + ); | ||
| + | ||
| + /** | ||
| + * Prevents re-instantiation of processing classes. | ||
| + */ | ||
| + private final Map<TextResource, Processor<String>> mProcessors = | ||
| + new HashMap<>(); | ||
| + | ||
| + private final Workspace mWorkspace; | ||
| + | ||
| + /** | ||
| + * Groups similar file type tabs together. | ||
| + */ | ||
| + private final List<TabPane> mTabPanes = new ArrayList<>(); | ||
| + | ||
| + /** | ||
| + * Stores definition names and values. | ||
| + */ | ||
| + private final Map<String, String> mResolvedMap = | ||
| + new HashMap<>( MAP_SIZE_DEFAULT ); | ||
| + | ||
| + /** | ||
| + * Renders the actively selected plain text editor tab. | ||
| + */ | ||
| + private final HtmlPreview mPreview; | ||
| + | ||
| + /** | ||
| + * Provides an interactive document outline. | ||
| + */ | ||
| + private final DocumentOutline mOutline = new DocumentOutline(); | ||
| + | ||
| + /** | ||
| + * Changing the active editor fires the value changed event. This allows | ||
| + * refreshes to happen when external definitions are modified and need to | ||
| + * trigger the processing chain. | ||
| + */ | ||
| + private final ObjectProperty<TextEditor> mActiveTextEditor = | ||
| + createActiveTextEditor(); | ||
| + | ||
| + /** | ||
| + * Changing the active definition editor fires the value changed event. This | ||
| + * allows refreshes to happen when external definitions are modified and need | ||
| + * to trigger the processing chain. | ||
| + */ | ||
| + private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | ||
| + createActiveDefinitionEditor( mActiveTextEditor ); | ||
| + | ||
| + /** | ||
| + * Tracks the number of detached tab panels opened into their own windows, | ||
| + * which allows unique identification of subordinate windows by their title. | ||
| + * It is doubtful more than 128 windows, much less 256, will be created. | ||
| + */ | ||
| + private byte mWindowCount; | ||
| + | ||
| + /** | ||
| + * Called when the definition data is changed. | ||
| + */ | ||
| + private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | ||
| + event -> { | ||
| + final var editor = mActiveDefinitionEditor.get(); | ||
| + | ||
| + resolve( editor ); | ||
| + process( getActiveTextEditor() ); | ||
| + save( editor ); | ||
| + }; | ||
| + | ||
| + private final DocumentStatistics mStatistics; | ||
| + | ||
| + /** | ||
| + * Adds all content panels to the main user interface. This will load the | ||
| + * configuration settings from the workspace to reproduce the settings from | ||
| + * a previous session. | ||
| + */ | ||
| + public MainPane( final Workspace workspace ) { | ||
| + mWorkspace = workspace; | ||
| + mPreview = new HtmlPreview( workspace ); | ||
| + mStatistics = new DocumentStatistics( workspace ); | ||
| + mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | ||
| + | ||
| + open( bin( getRecentFiles() ) ); | ||
| + viewPreview(); | ||
| + setDividerPositions( calculateDividerPositions() ); | ||
| + | ||
| + // Once the main scene's window regains focus, update the active definition | ||
| + // editor to the currently selected tab. | ||
| + runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | ||
| + // Order matters here. We want to close all the tabs to ensure each | ||
| + // is saved, but after they are closed, the workspace should still | ||
| + // retain the list of files that were open. If this line came after | ||
| + // closing, then restarting the application would list no files. | ||
| + mWorkspace.save(); | ||
| + | ||
| + if( closeAll() ) { | ||
| + Platform.exit(); | ||
| + System.exit( 0 ); | ||
| + } | ||
| + else { | ||
| + event.consume(); | ||
| + } | ||
| + } ) ); | ||
| + | ||
| + register( this ); | ||
| + } | ||
| + | ||
| + @Subscribe | ||
| + public void handle( final TextEditorFocusEvent event ) { | ||
| + mActiveTextEditor.set( event.get() ); | ||
| + } | ||
| + | ||
| + @Subscribe | ||
| + public void handle( final TextDefinitionFocusEvent event ) { | ||
| + mActiveDefinitionEditor.set( event.get() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Typically called when a file name is clicked in the {@link HtmlPanel}. | ||
| + * | ||
| + * @param event The event to process, must contain a valid file reference. | ||
| + */ | ||
| + @Subscribe | ||
| + public void handle( final FileOpenEvent event ) { | ||
| + final File eventFile; | ||
| + final var eventUri = event.getUri(); | ||
| + | ||
| + if( eventUri.isAbsolute() ) { | ||
| + eventFile = new File( eventUri.getPath() ); | ||
| + } | ||
| + else { | ||
| + final var activeFile = getActiveTextEditor().getFile(); | ||
| + final var parent = activeFile.getParentFile(); | ||
| + | ||
| + if( parent == null ) { | ||
| + clue( new FileNotFoundException( eventUri.getPath() ) ); | ||
| + return; | ||
| + } | ||
| + else { | ||
| + final var parentPath = parent.getAbsolutePath(); | ||
| + eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | ||
| + } | ||
| + } | ||
| + | ||
| + runLater( () -> open( eventFile ) ); | ||
| + } | ||
| + | ||
| + @Subscribe | ||
| + public void handle( final CaretNavigationEvent event ) { | ||
| + runLater( () -> { | ||
| + final var textArea = getActiveTextEditor().getTextArea(); | ||
| + textArea.moveTo( event.getOffset() ); | ||
| + textArea.requestFollowCaret(); | ||
| + textArea.requestFocus(); | ||
| + } ); | ||
| + } | ||
| + | ||
| + @Subscribe | ||
| + @SuppressWarnings( "unused" ) | ||
| + public void handle( final ExportFailedEvent event ) { | ||
| + final var os = getProperty( "os.name" ); | ||
| + final var arch = getProperty( "os.arch" ).toLowerCase(); | ||
| + final var bits = getProperty( "sun.arch.data.model" ); | ||
| + | ||
| + final var title = Messages.get( "Alert.typesetter.missing.title" ); | ||
| + final var header = Messages.get( "Alert.typesetter.missing.header" ); | ||
| + final var version = Messages.get( | ||
| + "Alert.typesetter.missing.version", | ||
| + os, | ||
| + arch | ||
| + .replaceAll( "amd.*|i.*|x86.*", "X86" ) | ||
| + .replaceAll( "mips.*", "MIPS" ) | ||
| + .replaceAll( "armv.*", "ARM" ), | ||
| + bits ); | ||
| + final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | ||
| + | ||
| + // Download and install ConTeXt for {0} {1} {2}-bit | ||
| + final var content = format( "%s %s", text, version ); | ||
| + final var flowPane = new FlowPane(); | ||
| + final var link = new Hyperlink( text ); | ||
| + final var label = new Label( version ); | ||
| + flowPane.getChildren().addAll( link, label ); | ||
| + | ||
| + final var alert = new Alert( ERROR, content, OK ); | ||
| + alert.setTitle( title ); | ||
| + alert.setHeaderText( header ); | ||
| + alert.getDialogPane().contentProperty().set( flowPane ); | ||
| + alert.setGraphic( ICON_DIALOG_NODE ); | ||
| + | ||
| + link.setOnAction( ( e ) -> { | ||
| + alert.close(); | ||
| + final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | ||
| + runLater( () -> fireHyperlinkOpenEvent( url ) ); | ||
| + } ); | ||
| + | ||
| + alert.showAndWait(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * TODO: Load divider positions from exported settings, see bin() comment. | ||
| + */ | ||
| + private double[] calculateDividerPositions() { | ||
| + final var ratio = 100f / getItems().size() / 100; | ||
| + final var positions = getDividerPositions(); | ||
| + | ||
| + for( int i = 0; i < positions.length; i++ ) { | ||
| + positions[ i ] = ratio * i; | ||
| + } | ||
| + | ||
| + return positions; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Opens all the files into the application, provided the paths are unique. | ||
| + * This may only be called for any type of files that a user can edit | ||
| + * (i.e., update and persist), such as definitions and text files. | ||
| + * | ||
| + * @param files The list of files to open. | ||
| + */ | ||
| + public void open( final List<File> files ) { | ||
| + files.forEach( this::open ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * This opens the given file. Since the preview pane is not a file that | ||
| + * can be opened, it is safe to add a listener to the detachable pane. | ||
| + * | ||
| + * @param file The file to open. | ||
| + */ | ||
| + private void open( final File file ) { | ||
| + final var tab = createTab( file ); | ||
| + final var node = tab.getContent(); | ||
| + final var mediaType = MediaType.valueFrom( file ); | ||
| + final var tabPane = obtainTabPane( mediaType ); | ||
| + | ||
| + tab.setTooltip( createTooltip( file ) ); | ||
| + tabPane.setFocusTraversable( false ); | ||
| + tabPane.setTabClosingPolicy( ALL_TABS ); | ||
| + tabPane.getTabs().add( tab ); | ||
| + | ||
| + // Attach the tab scene factory for new tab panes. | ||
| + if( !getItems().contains( tabPane ) ) { | ||
| + addTabPane( | ||
| + node instanceof TextDefinition ? 0 : getItems().size(), tabPane | ||
| + ); | ||
| + } | ||
| + | ||
| + getRecentFiles().add( file.getAbsolutePath() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Opens a new text editor document using the default document file name. | ||
| + */ | ||
| + public void newTextEditor() { | ||
| + open( DOCUMENT_DEFAULT ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Opens a new definition editor document using the default definition | ||
| + * file name. | ||
| + */ | ||
| + public void newDefinitionEditor() { | ||
| + open( DEFINITION_DEFAULT ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Iterates over all tab panes to find all {@link TextEditor}s and request | ||
| + * that they save themselves. | ||
| + */ | ||
| + public void saveAll() { | ||
| + mTabPanes.forEach( | ||
| + ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | ||
| + final var node = tab.getContent(); | ||
| + if( node instanceof final TextEditor editor ) { | ||
| + save( editor ); | ||
| + } | ||
| + } ) | ||
| + ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Requests that the active {@link TextEditor} saves itself. Don't bother | ||
| + * checking if modified first because if the user swaps external media from | ||
| + * an external source (e.g., USB thumb drive), save should not second-guess | ||
| + * the user: save always re-saves. Also, it's less code. | ||
| + */ | ||
| + public void save() { | ||
| + save( getActiveTextEditor() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Saves the active {@link TextEditor} under a new name. | ||
| + * | ||
| + * @param files The new active editor {@link File} reference, must contain | ||
| + * at least one element. | ||
| + */ | ||
| + public void saveAs( final List<File> files ) { | ||
| + assert files != null; | ||
| + assert !files.isEmpty(); | ||
| + final var editor = getActiveTextEditor(); | ||
| + final var tab = getTab( editor ); | ||
| + final var file = files.get( 0 ); | ||
| + | ||
| + editor.rename( file ); | ||
| + tab.ifPresent( t -> { | ||
| + t.setText( editor.getFilename() ); | ||
| + t.setTooltip( createTooltip( file ) ); | ||
| + } ); | ||
| + | ||
| + save(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Saves the given {@link TextResource} to a file. This is typically used | ||
| + * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | ||
| + * | ||
| + * @param resource The resource to export. | ||
| + */ | ||
| + private void save( final TextResource resource ) { | ||
| + try { | ||
| + resource.save(); | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + sNotifier.alert( | ||
| + getWindow(), resource.getPath(), "TextResource.saveFailed", ex | ||
| + ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | ||
| + * | ||
| + * @return {@code true} when all editors, modified or otherwise, were | ||
| + * permitted to close; {@code false} when one or more editors were modified | ||
| + * and the user requested no closing. | ||
| + */ | ||
| + public boolean closeAll() { | ||
| + var closable = true; | ||
| + | ||
| + for( final var tabPane : mTabPanes ) { | ||
| + final var tabIterator = tabPane.getTabs().iterator(); | ||
| + | ||
| + while( tabIterator.hasNext() ) { | ||
| + final var tab = tabIterator.next(); | ||
| + final var resource = tab.getContent(); | ||
| + | ||
| + // The definition panes auto-save, so being specific here prevents | ||
| + // closing the definitions in the situation where the user wants to | ||
| + // continue editing (i.e., possibly save unsaved work). | ||
| + if( !(resource instanceof TextEditor) ) { | ||
| + continue; | ||
| + } | ||
| + | ||
| + if( canClose( (TextEditor) resource ) ) { | ||
| + tabIterator.remove(); | ||
| + close( tab ); | ||
| + } | ||
| + else { | ||
| + closable = false; | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + return closable; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | ||
| + * event. | ||
| + * | ||
| + * @param tab The {@link Tab} that was closed. | ||
| + */ | ||
| + private void close( final Tab tab ) { | ||
| + final var handler = tab.getOnClosed(); | ||
| + | ||
| + if( handler != null ) { | ||
| + handler.handle( new ActionEvent() ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Closes the active tab; delegates to {@link #canClose(TextResource)}. | ||
| + */ | ||
| + public void close() { | ||
| + final var editor = getActiveTextEditor(); | ||
| + | ||
| + if( canClose( editor ) ) { | ||
| + close( editor ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Closes the given {@link TextResource}. This must not be called from within | ||
| + * a loop that iterates over the tab panes using {@code forEach}, lest a | ||
| + * concurrent modification exception be thrown. | ||
| + * | ||
| + * @param resource The {@link TextResource} to close, without confirming with | ||
| + * the user. | ||
| + */ | ||
| + private void close( final TextResource resource ) { | ||
| + getTab( resource ).ifPresent( | ||
| + ( tab ) -> { | ||
| + tab.getTabPane().getTabs().remove( tab ); | ||
| + close( tab ); | ||
| + } | ||
| + ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Answers whether the given {@link TextResource} may be closed. | ||
| + * | ||
| + * @param editor The {@link TextResource} to try closing. | ||
| + * @return {@code true} when the editor may be closed; {@code false} when | ||
| + * the user has requested to keep the editor open. | ||
| + */ | ||
| + private boolean canClose( final TextResource editor ) { | ||
| + final var editorTab = getTab( editor ); | ||
| + final var canClose = new AtomicBoolean( true ); | ||
| + | ||
| + if( editor.isModified() ) { | ||
| + final var filename = new StringBuilder(); | ||
| + editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | ||
| + | ||
| + final var message = sNotifier.createNotification( | ||
| + Messages.get( "Alert.file.close.title" ), | ||
| + Messages.get( "Alert.file.close.text" ), | ||
| + filename.toString() | ||
| + ); | ||
| + | ||
| + final var dialog = sNotifier.createConfirmation( getWindow(), message ); | ||
| + | ||
| + dialog.showAndWait().ifPresent( | ||
| + save -> canClose.set( save == YES ? editor.save() : save == NO ) | ||
| + ); | ||
| + } | ||
| + | ||
| + return canClose.get(); | ||
| + } | ||
| + | ||
| + private ObjectProperty<TextEditor> createActiveTextEditor() { | ||
| + final var editor = new SimpleObjectProperty<TextEditor>(); | ||
| + | ||
| + editor.addListener( ( c, o, n ) -> { | ||
| + if( n != null ) { | ||
| + mPreview.setBaseUri( n.getPath() ); | ||
| + process( n ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + return editor; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Adds the HTML preview tab to its own, singular tab pane. | ||
| + */ | ||
| + public void viewPreview() { | ||
| + viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Adds the document outline tab to its own, singular tab pane. | ||
| + */ | ||
| + public void viewOutline() { | ||
| + viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | ||
| + } | ||
| + | ||
| + public void viewStatistics() { | ||
| + viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | ||
| + } | ||
| + | ||
| + public void viewFiles() { | ||
| + try { | ||
| + final var factory = new FilePickerFactory( mWorkspace ); | ||
| + final var fileManager = factory.createModeless(); | ||
| + viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + } | ||
| + } | ||
| + | ||
| + private void viewTab( | ||
| + final Node node, final MediaType mediaType, final String key ) { | ||
| + final var tabPane = obtainTabPane( mediaType ); | ||
| + | ||
| + for( final var tab : tabPane.getTabs() ) { | ||
| + if( tab.getContent() == node ) { | ||
| + return; | ||
| + } | ||
| + } | ||
| + | ||
| + tabPane.getTabs().add( createTab( get( key ), node ) ); | ||
| + addTabPane( tabPane ); | ||
| + } | ||
| + | ||
| + public void viewRefresh() { | ||
| + mPreview.refresh(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the tab that contains the given {@link TextEditor}. | ||
| + * | ||
| + * @param editor The {@link TextEditor} instance to find amongst the tabs. | ||
| + * @return The first tab having content that matches the given tab. | ||
| + */ | ||
| + private Optional<Tab> getTab( final TextResource editor ) { | ||
| + return mTabPanes.stream() | ||
| + .flatMap( pane -> pane.getTabs().stream() ) | ||
| + .filter( tab -> editor.equals( tab.getContent() ) ) | ||
| + .findFirst(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a new {@link DefinitionEditor} wrapped in a listener that | ||
| + * is used to detect when the active {@link DefinitionEditor} has changed. | ||
| + * Upon changing, the {@link #mResolvedMap} is updated and the active | ||
| + * text editor is refreshed. | ||
| + * | ||
| + * @param editor Text editor to update with the revised resolved map. | ||
| + * @return A newly configured property that represents the active | ||
| + * {@link DefinitionEditor}, never null. | ||
| + */ | ||
| + private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | ||
| + final ObjectProperty<TextEditor> editor ) { | ||
| + final var definitions = new SimpleObjectProperty<TextDefinition>(); | ||
| + definitions.addListener( ( c, o, n ) -> { | ||
| + resolve( n == null ? createDefinitionEditor() : n ); | ||
| + process( editor.get() ); | ||
| + } ); | ||
| + | ||
| + return definitions; | ||
| + } | ||
| + | ||
| + private Tab createTab( final String filename, final Node node ) { | ||
| + return new DetachableTab( filename, node ); | ||
| + } | ||
| + | ||
| + private Tab createTab( final File file ) { | ||
| + final var r = createTextResource( file ); | ||
| + final var tab = createTab( r.getFilename(), r.getNode() ); | ||
| + | ||
| + r.modifiedProperty().addListener( | ||
| + ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | ||
| + ); | ||
| + | ||
| + // This is called when either the tab is closed by the user clicking on | ||
| + // the tab's close icon or when closing (all) from the file menu. | ||
| + tab.setOnClosed( | ||
| + ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | ||
| + ); | ||
| + | ||
| + tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | ||
| + if( nPane != null ) { | ||
| + nPane.focusedProperty().addListener( ( c, o, n ) -> { | ||
| + if( n != null && n ) { | ||
| + final var selected = nPane.getSelectionModel().getSelectedItem(); | ||
| + final var node = selected.getContent(); | ||
| + node.requestFocus(); | ||
| + } | ||
| + } ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + return tab; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates bins for the different {@link MediaType}s, which eventually are | ||
| + * added to the UI as separate tab panes. If ever a general-purpose scene | ||
| + * exporter is developed to serialize a scene to an FXML file, this could | ||
| + * be replaced by such a class. | ||
| + * <p> | ||
| + * When binning the files, this makes sure that at least one file exists | ||
| + * for every type. If the user has opted to close a particular type (such | ||
| + * as the definition pane), the view will suppressed elsewhere. | ||
| + * </p> | ||
| + * <p> | ||
| + * The order that the binned files are returned will be reflected in the | ||
| + * order that the corresponding panes are rendered in the UI. | ||
| + * </p> | ||
| + * | ||
| + * @param paths The file paths to bin according to their type. | ||
| + * @return An in-order list of files, first by structured definition files, | ||
| + * then by plain text documents. | ||
| + */ | ||
| + private List<File> bin( final SetProperty<String> paths ) { | ||
| + // Treat all files destined for the text editor as plain text documents | ||
| + // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | ||
| + // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | ||
| + final Function<MediaType, MediaType> bin = | ||
| + m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | ||
| + | ||
| + // Create two groups: YAML files and plain text files. | ||
| + final var bins = paths | ||
| + .stream() | ||
| + .collect( | ||
| + groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) ) | ||
| + ); | ||
| + | ||
| + bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | ||
| + bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | ||
| + | ||
| + final var result = new ArrayList<File>( paths.size() ); | ||
| + | ||
| + // Ensure that the same types are listed together (keep insertion order). | ||
| + bins.forEach( ( mediaType, files ) -> result.addAll( | ||
| + files.stream().map( File::new ).collect( Collectors.toList() ) ) | ||
| + ); | ||
| + | ||
| + return result; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Uses the given {@link TextDefinition} instance to update the | ||
| + * {@link #mResolvedMap}. | ||
| + * | ||
| + * @param editor A non-null, possibly empty definition editor. | ||
| + */ | ||
| + private void resolve( final TextDefinition editor ) { | ||
| + assert editor != null; | ||
| + | ||
| + final var tokens = createDefinitionTokens(); | ||
| + final var operator = new YamlSigilOperator( tokens ); | ||
| + final var map = new HashMap<String, String>(); | ||
| + | ||
| + editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | ||
| + | ||
| + mResolvedMap.clear(); | ||
| + mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Force the active editor to update, which will cause the processor | ||
| + * to re-evaluate the interpolated definition map thereby updating the | ||
| + * preview pane. | ||
| + * | ||
| + * @param editor Contains the source document to update in the preview pane. | ||
| + */ | ||
| + private void process( final TextEditor editor ) { | ||
| + // Ensure processing does not run on the JavaFX thread, which frees the | ||
| + // text editor immediately for caret movement. The preview will have a | ||
| + // slight delay when catching up to the caret position. | ||
| + final var task = new Task<Void>() { | ||
| + @Override | ||
| + public Void call() { | ||
| + try { | ||
| + final var p = mProcessors.getOrDefault( editor, IDENTITY ); | ||
| + p.apply( editor == null ? "" : editor.getText() ); | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + } | ||
| + | ||
| + return null; | ||
| + } | ||
| + }; | ||
| + | ||
| + task.setOnSucceeded( | ||
| + e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | ||
| + ); | ||
| + | ||
| + // Prevents multiple process requests from executing simultaneously (due | ||
| + // to having a restricted queue size). | ||
| + sExecutor.execute( task ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Lazily creates a {@link TabPane} configured to listen for tab select | ||
| + * events. The tab pane is associated with a given media type so that | ||
| + * similar files can be grouped together. | ||
| + * | ||
| + * @param mediaType The media type to associate with the tab pane. | ||
| + * @return An instance of {@link TabPane} that will handle tab docking. | ||
| + */ | ||
| + private TabPane obtainTabPane( final MediaType mediaType ) { | ||
| + for( final var pane : mTabPanes ) { | ||
| + for( final var tab : pane.getTabs() ) { | ||
| + final var node = tab.getContent(); | ||
| + | ||
| + if( node instanceof TextResource r && r.supports( mediaType ) ) { | ||
| + return pane; | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + final var pane = createTabPane(); | ||
| + mTabPanes.add( pane ); | ||
| + return pane; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates an initialized {@link TabPane} instance. | ||
| + * | ||
| + * @return A new {@link TabPane} with all listeners configured. | ||
| + */ | ||
| + private TabPane createTabPane() { | ||
| + final var tabPane = new DetachableTabPane(); | ||
| + | ||
| + initStageOwnerFactory( tabPane ); | ||
| + initTabListener( tabPane ); | ||
| + | ||
| + return tabPane; | ||
| + } | ||
| + | ||
| + /** | ||
| + * When any {@link DetachableTabPane} is detached from the main window, | ||
| + * the stage owner factory must be given its parent window, which will | ||
| + * own the child window. The parent window is the {@link MainPane}'s | ||
| + * {@link Scene}'s {@link Window} instance. | ||
| + * | ||
| + * <p> | ||
| + * This will derives the new title from the main window title, incrementing | ||
| + * the window count to help uniquely identify the child windows. | ||
| + * </p> | ||
| + * | ||
| + * @param tabPane A new {@link DetachableTabPane} to configure. | ||
| + */ | ||
| + private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | ||
| + tabPane.setStageOwnerFactory( ( stage ) -> { | ||
| + final var title = get( | ||
| + "Detach.tab.title", | ||
| + ((Stage) getWindow()).getTitle(), ++mWindowCount | ||
| + ); | ||
| + stage.setTitle( title ); | ||
| + | ||
| + return getScene().getWindow(); | ||
| + } ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Responsible for configuring the content of each {@link DetachableTab} when | ||
| + * it is added to the given {@link DetachableTabPane} instance. | ||
| + * <p> | ||
| + * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | ||
| + * is initialized to perform synchronized scrolling between the editor and | ||
| + * its preview window. Additionally, the last tab in the tab pane's list of | ||
| + * tabs is given focus. | ||
| + * </p> | ||
| + * <p> | ||
| + * Note that multiple tabs can be added simultaneously. | ||
| + * </p> | ||
| + * | ||
| + * @param tabPane A new {@link TabPane} to configure. | ||
| + */ | ||
| + private void initTabListener( final TabPane tabPane ) { | ||
| + tabPane.getTabs().addListener( | ||
| + ( final ListChangeListener.Change<? extends Tab> listener ) -> { | ||
| + while( listener.next() ) { | ||
| + if( listener.wasAdded() ) { | ||
| + final var tabs = listener.getAddedSubList(); | ||
| + | ||
| + tabs.forEach( ( tab ) -> { | ||
| + final var node = tab.getContent(); | ||
| + | ||
| + if( node instanceof TextEditor ) { | ||
| + initScrollEventListener( tab ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + // Select and give focus to the last tab opened. | ||
| + final var index = tabs.size() - 1; | ||
| + if( index >= 0 ) { | ||
| + final var tab = tabs.get( index ); | ||
| + tabPane.getSelectionModel().select( tab ); | ||
| + tab.getContent().requestFocus(); | ||
| + } | ||
| + } | ||
| + } | ||
| + } | ||
| + ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Synchronizes scrollbar positions between the given {@link Tab} that | ||
| + * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | ||
| + * | ||
| + * @param tab The container for an instance of {@link TextEditor}. | ||
| + */ | ||
| + private void initScrollEventListener( final Tab tab ) { | ||
| + final var editor = (TextEditor) tab.getContent(); | ||
| + final var scrollPane = editor.getScrollPane(); | ||
| + final var scrollBar = mPreview.getVerticalScrollBar(); | ||
| + final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | ||
| + handler.enabledProperty().bind( tab.selectedProperty() ); | ||
| + } | ||
| + | ||
| + private void addTabPane( final int index, final TabPane tabPane ) { | ||
| + final var items = getItems(); | ||
| + if( !items.contains( tabPane ) ) { | ||
| + items.add( index, tabPane ); | ||
| + } | ||
| + } | ||
| + | ||
| + private void addTabPane( final TabPane tabPane ) { | ||
| + addTabPane( getItems().size(), tabPane ); | ||
| + } | ||
| + | ||
| + public ProcessorContext createProcessorContext() { | ||
| + return createProcessorContext( null, NONE ); | ||
| + } | ||
| + | ||
| + public ProcessorContext createProcessorContext( | ||
| + final Path exportPath, final ExportFormat format ) { | ||
| + final var editor = getActiveTextEditor(); | ||
| + return createProcessorContext( | ||
| + editor.getPath(), exportPath, format, editor.getCaret() ); | ||
| + } | ||
| + | ||
| + private ProcessorContext createProcessorContext( | ||
| + final Path path, final Caret caret ) { | ||
| + return createProcessorContext( path, null, ExportFormat.NONE, caret ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param path Used by {@link ProcessorFactory} to determine | ||
| + * {@link Processor} type to create based on file type. | ||
| + * @param exportPath Used when exporting to a PDF file (binary). | ||
| + * @param format Used when processors export to a new text format. | ||
| + * @param caret Used by {@link CaretExtension} to add ID attribute into | ||
| + * preview document for scrollbar synchronization. | ||
| + * @return A new {@link ProcessorContext} to use when creating an instance of | ||
| + * {@link Processor}. | ||
| + */ | ||
| + private ProcessorContext createProcessorContext( | ||
| + final Path path, final Path exportPath, final ExportFormat format, | ||
| + final Caret caret ) { | ||
| + return new ProcessorContext( | ||
| + mPreview, mResolvedMap, path, exportPath, format, mWorkspace, caret | ||
| + ); | ||
| + } | ||
| + | ||
| + private TextResource createTextResource( final File file ) { | ||
| + // TODO: Create PlainTextEditor that's returned by default. | ||
| + return MediaType.valueFrom( file ) == TEXT_YAML | ||
| + ? createDefinitionEditor( file ) | ||
| + : createMarkdownEditor( file ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates an instance of {@link MarkdownEditor} that listens for both | ||
| + * caret change events and text change events. Text change events must | ||
| + * take priority over caret change events because it's possible to change | ||
| + * the text without moving the caret (e.g., delete selected text). | ||
| + * | ||
| + * @param file The file containing contents for the text editor. | ||
| + * @return A non-null text editor. | ||
| + */ | ||
| + private TextResource createMarkdownEditor( final File file ) { | ||
| + final var path = file.toPath(); | ||
| + final var editor = new MarkdownEditor( file, getWorkspace() ); | ||
| + final var caret = editor.getCaret(); | ||
| + final var context = createProcessorContext( path, caret ); | ||
| + | ||
| + mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | ||
| + | ||
| + editor.addDirtyListener( ( c, o, n ) -> { | ||
| + if( n ) { | ||
| + // Reset the status to OK after changing the text. | ||
| + clue(); | ||
| + | ||
| + // Processing the text may update the status bar. | ||
| + process( getActiveTextEditor() ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + editor.addEventListener( | ||
| + keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | ||
| + ); | ||
| + | ||
| + // Set the active editor, which refreshes the preview panel. | ||
| + mActiveTextEditor.set( editor ); | ||
| + | ||
| + return editor; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Delegates to {@link #autoinsert()}. | ||
| + * | ||
| + * @param event Ignored. | ||
| + */ | ||
| + private void autoinsert( final KeyEvent event ) { | ||
| + autoinsert(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Finds a node that matches the word at the caret, then inserts the | ||
| + * corresponding definition. The definition token delimiters depend on | ||
| + * the type of file being edited. | ||
| + */ | ||
| + public void autoinsert() { | ||
| + final var definitions = getActiveTextDefinition(); | ||
| + final var editor = getActiveTextEditor(); | ||
| + final var mediaType = editor.getMediaType(); | ||
| + final var operator = getSigilOperator( mediaType ); | ||
| + | ||
| + DefinitionNameInjector.autoinsert( editor, definitions, operator ); | ||
| + } | ||
| + | ||
| + private TextDefinition createDefinitionEditor() { | ||
| + return createDefinitionEditor( DEFINITION_DEFAULT ); | ||
| + } | ||
| + | ||
| + private TextDefinition createDefinitionEditor( final File file ) { | ||
| + final var editor = new DefinitionEditor( file, createTreeTransformer() ); | ||
| + editor.addTreeChangeHandler( mTreeHandler ); | ||
| + return editor; | ||
| + } | ||
| + | ||
| + private TreeTransformer createTreeTransformer() { | ||
| + return new YamlTreeTransformer(); | ||
| + } | ||
| + | ||
| + private Tooltip createTooltip( final File file ) { | ||
| + final var path = file.toPath(); | ||
| + final var tooltip = new Tooltip( path.toString() ); | ||
| + | ||
| + tooltip.setShowDelay( millis( 200 ) ); | ||
| + return tooltip; | ||
| + } | ||
| + | ||
| + public TextEditor getActiveTextEditor() { | ||
| + return mActiveTextEditor.get(); | ||
| + } | ||
| + | ||
| + public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() { | ||
| + return mActiveTextEditor; | ||
| + } | ||
| + | ||
| + public TextDefinition getActiveTextDefinition() { | ||
| + return mActiveDefinitionEditor.get(); | ||
| + } | ||
| + | ||
| + public Window getWindow() { | ||
| + return getScene().getWindow(); | ||
| + } | ||
| + | ||
| + public Workspace getWorkspace() { | ||
| + return mWorkspace; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the sigil operator for the given {@link MediaType}. | ||
| + * | ||
| + * @param mediaType The type of file being edited. | ||
| + */ | ||
| + private SigilOperator getSigilOperator( final MediaType mediaType ) { | ||
| + final var operator = new YamlSigilOperator( createDefinitionTokens() ); | ||
| + | ||
| + return mediaType == TEXT_R_MARKDOWN | ||
| + ? new RSigilOperator( createRTokens(), operator ) | ||
| + : operator; | ||
| } | ||
| import static com.keenwrite.events.StatusEvent.clue; | ||
| import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus; | ||
| -import static com.keenwrite.io.MediaType.*; | ||
| -import static com.keenwrite.preferences.WorkspaceKeys.*; | ||
| -import static java.lang.Character.isWhitespace; | ||
| -import static java.lang.String.format; | ||
| -import static java.util.Collections.singletonList; | ||
| -import static javafx.application.Platform.runLater; | ||
| -import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | ||
| -import static javafx.scene.input.KeyCode.*; | ||
| -import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | ||
| -import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | ||
| -import static org.apache.commons.lang3.StringUtils.stripEnd; | ||
| -import static org.apache.commons.lang3.StringUtils.stripStart; | ||
| -import static org.fxmisc.richtext.model.StyleSpans.singleton; | ||
| -import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | ||
| -import static org.fxmisc.wellbehaved.event.InputMap.consume; | ||
| - | ||
| -/** | ||
| - * Responsible for editing Markdown documents. | ||
| - */ | ||
| -public final class MarkdownEditor extends BorderPane implements TextEditor { | ||
| - /** | ||
| - * Regular expression that matches the type of markup block. This is used | ||
| - * when Enter is pressed to continue the block environment. | ||
| - */ | ||
| - private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | ||
| - "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | ||
| - | ||
| - /** | ||
| - * The text editor. | ||
| - */ | ||
| - private final StyleClassedTextArea mTextArea = | ||
| - new StyleClassedTextArea( false ); | ||
| - | ||
| - /** | ||
| - * Wraps the text editor in scrollbars. | ||
| - */ | ||
| - private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | ||
| - new VirtualizedScrollPane<>( mTextArea ); | ||
| - | ||
| - private final Workspace mWorkspace; | ||
| - | ||
| - /** | ||
| - * Tracks where the caret is located in this document. This offers observable | ||
| - * properties for caret position changes. | ||
| - */ | ||
| - private final Caret mCaret = createCaret( mTextArea ); | ||
| - | ||
| - /** | ||
| - * File being edited by this editor instance. | ||
| - */ | ||
| - private File mFile; | ||
| - | ||
| - /** | ||
| - * Set to {@code true} upon text or caret position changes. Value is {@code | ||
| - * false} by default. | ||
| - */ | ||
| - private final BooleanProperty mDirty = new SimpleBooleanProperty(); | ||
| - | ||
| - /** | ||
| - * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | ||
| - * either no encoding could be determined or this is a new (empty) file. | ||
| - */ | ||
| - private final Charset mEncoding; | ||
| - | ||
| - /** | ||
| - * Tracks whether the in-memory definitions have changed with respect to the | ||
| - * persisted definitions. | ||
| - */ | ||
| - private final BooleanProperty mModified = new SimpleBooleanProperty(); | ||
| - | ||
| - public MarkdownEditor( final Workspace workspace ) { | ||
| - this( DOCUMENT_DEFAULT, workspace ); | ||
| - } | ||
| - | ||
| - public MarkdownEditor( final File file, final Workspace workspace ) { | ||
| - mEncoding = open( mFile = file ); | ||
| - mWorkspace = workspace; | ||
| - | ||
| - initTextArea( mTextArea ); | ||
| - initStyle( mTextArea ); | ||
| - initScrollPane( mScrollPane ); | ||
| - initSpellchecker( mTextArea ); | ||
| - initHotKeys(); | ||
| - initUndoManager(); | ||
| - } | ||
| - | ||
| - private void initTextArea( final StyleClassedTextArea textArea ) { | ||
| - textArea.setWrapText( true ); | ||
| - textArea.requestFollowCaret(); | ||
| - textArea.moveTo( 0 ); | ||
| - | ||
| - textArea.textProperty().addListener( ( c, o, n ) -> { | ||
| - // Fire, regardless of whether the caret position has changed. | ||
| - mDirty.set( false ); | ||
| - | ||
| - // Prevent a caret position change from raising the dirty bits. | ||
| - mDirty.set( true ); | ||
| - } ); | ||
| - | ||
| - textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | ||
| - // Fire when the caret position has changed and the text has not. | ||
| - mDirty.set( true ); | ||
| - mDirty.set( false ); | ||
| - } ); | ||
| - | ||
| - textArea.focusedProperty().addListener( ( c, o, n ) -> { | ||
| - if( n != null && n ) { | ||
| - fireTextEditorFocus( this ); | ||
| - } | ||
| - } ); | ||
| - } | ||
| - | ||
| - private void initStyle( final StyleClassedTextArea textArea ) { | ||
| - textArea.getStyleClass().add( "markdown" ); | ||
| - | ||
| - final var stylesheets = textArea.getStylesheets(); | ||
| - stylesheets.add( getStylesheetPath( getLocale() ) ); | ||
| - | ||
| - localeProperty().addListener( ( c, o, n ) -> { | ||
| - if( n != null ) { | ||
| - stylesheets.clear(); | ||
| - stylesheets.add( getStylesheetPath( getLocale() ) ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - fontNameProperty().addListener( | ||
| - ( c, o, n ) -> | ||
| - setFont( mTextArea, getFontName(), getFontSize() ) | ||
| - ); | ||
| - | ||
| - fontSizeProperty().addListener( | ||
| - ( c, o, n ) -> | ||
| - setFont( mTextArea, getFontName(), getFontSize() ) | ||
| - ); | ||
| - | ||
| - setFont( mTextArea, getFontName(), getFontSize() ); | ||
| - } | ||
| - | ||
| - private void initScrollPane( | ||
| - final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | ||
| - scrollpane.setVbarPolicy( ALWAYS ); | ||
| - setCenter( scrollpane ); | ||
| - } | ||
| - | ||
| - private void initSpellchecker( final StyleClassedTextArea textarea ) { | ||
| - final var speller = new TextEditorSpeller(); | ||
| - speller.checkDocument( textarea ); | ||
| - speller.checkParagraphs( textarea ); | ||
| - } | ||
| - | ||
| - private void initHotKeys() { | ||
| - addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | ||
| - addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | ||
| - addEventListener( keyPressed( TAB ), this::tab ); | ||
| - addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | ||
| - addEventListener( keyPressed( INSERT ), this::onInsertPressed ); | ||
| - } | ||
| - | ||
| - private void initUndoManager() { | ||
| - final var undoManager = getUndoManager(); | ||
| - final var markedPosition = undoManager.atMarkedPositionProperty(); | ||
| - | ||
| - undoManager.forgetHistory(); | ||
| - undoManager.mark(); | ||
| - mModified.bind( Bindings.not( markedPosition ) ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void moveTo( final int offset ) { | ||
| - assert 0 <= offset && offset <= mTextArea.getLength(); | ||
| - mTextArea.moveTo( offset ); | ||
| - mTextArea.requestFollowCaret(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Delegate the focus request to the text area itself. | ||
| - */ | ||
| - @Override | ||
| - public void requestFocus() { | ||
| - mTextArea.requestFocus(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void setText( final String text ) { | ||
| - mTextArea.clear(); | ||
| - mTextArea.appendText( text ); | ||
| - mTextArea.getUndoManager().mark(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public String getText() { | ||
| - return mTextArea.getText(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public Charset getEncoding() { | ||
| - return mEncoding; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public File getFile() { | ||
| - return mFile; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void rename( final File file ) { | ||
| - mFile = file; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void undo() { | ||
| - final var manager = getUndoManager(); | ||
| - xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void redo() { | ||
| - final var manager = getUndoManager(); | ||
| - xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Performs an undo or redo action, if possible, otherwise displays an error | ||
| - * message to the user. | ||
| - * | ||
| - * @param ready Answers whether the action can be executed. | ||
| - * @param action The action to execute. | ||
| - * @param key The informational message key having a value to display if | ||
| - * the {@link Supplier} is not ready. | ||
| - */ | ||
| - private void xxdo( | ||
| - final Supplier<Boolean> ready, final Runnable action, final String key ) { | ||
| - if( ready.get() ) { | ||
| - action.run(); | ||
| - } | ||
| - else { | ||
| - clue( key ); | ||
| - } | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void cut() { | ||
| - final var selected = mTextArea.getSelectedText(); | ||
| - | ||
| - // Emulate selecting the current line by firing Home then Shift+Down Arrow. | ||
| - if( selected == null || selected.isEmpty() ) { | ||
| - // Note: mTextArea.selectLine() does not select empty lines. | ||
| - mTextArea.fireEvent( keyDown( HOME, false ) ); | ||
| - mTextArea.fireEvent( keyDown( DOWN, true ) ); | ||
| - } | ||
| - | ||
| - mTextArea.cut(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void copy() { | ||
| - mTextArea.copy(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void paste() { | ||
| - mTextArea.paste(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void selectAll() { | ||
| - mTextArea.selectAll(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void bold() { | ||
| - enwrap( "**" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void italic() { | ||
| - enwrap( "*" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void monospace() { | ||
| - enwrap( "`" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void superscript() { | ||
| - enwrap( "^" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void subscript() { | ||
| - enwrap( "~" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void strikethrough() { | ||
| - enwrap( "~~" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void blockquote() { | ||
| - block( "> " ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void code() { | ||
| - enwrap( "`" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void fencedCodeBlock() { | ||
| - enwrap( "\n\n```\n", "\n```\n\n" ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void heading( final int level ) { | ||
| - final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | ||
| - block( format( "%s ", hashes ) ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void unorderedList() { | ||
| - block( "* " ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void orderedList() { | ||
| - block( "1. " ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void horizontalRule() { | ||
| - block( format( "---%n%n" ) ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public Node getNode() { | ||
| - return this; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public ReadOnlyBooleanProperty modifiedProperty() { | ||
| - return mModified; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void clearModifiedProperty() { | ||
| - getUndoManager().mark(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | ||
| - return mScrollPane; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public StyleClassedTextArea getTextArea() { | ||
| - return mTextArea; | ||
| - } | ||
| - | ||
| - private final Map<String, IndexRange> mStyles = new HashMap<>(); | ||
| - | ||
| - @Override | ||
| - public void stylize( final IndexRange range, final String style ) { | ||
| - final var began = range.getStart(); | ||
| - final var ended = range.getEnd() + 1; | ||
| - | ||
| - assert 0 <= began && began <= ended; | ||
| - assert style != null; | ||
| - | ||
| - // TODO: Ensure spell check and find highlights can coexist. | ||
| -// final var spans = mTextArea.getStyleSpans( range ); | ||
| -// System.out.println( "SPANS: " + spans ); | ||
| - | ||
| -// final var spans = mTextArea.getStyleSpans( range ); | ||
| -// mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | ||
| -// ) ); | ||
| - | ||
| -// final var builder = new StyleSpansBuilder<Collection<String>>(); | ||
| -// builder.add( singleton( style ), range.getLength() + 1 ); | ||
| -// mTextArea.setStyleSpans( began, builder.create() ); | ||
| - | ||
| -// final var s = mTextArea.getStyleSpans( began, ended ); | ||
| -// System.out.println( "STYLES: " +s ); | ||
| - | ||
| - mStyles.put( style, range ); | ||
| - mTextArea.setStyleClass( began, ended, style ); | ||
| - | ||
| - // Ensure that whenever the user interacts with the text that the found | ||
| - // word will have its highlighting removed. The handler removes itself. | ||
| - // This won't remove the highlighting if the caret position moves by mouse. | ||
| - final var handler = mTextArea.getOnKeyPressed(); | ||
| - mTextArea.setOnKeyPressed( ( event ) -> { | ||
| - mTextArea.setOnKeyPressed( handler ); | ||
| - unstylize( style ); | ||
| - } ); | ||
| - | ||
| - //mTextArea.setStyleSpans(began, ended, s); | ||
| - } | ||
| - | ||
| - private static StyleSpans<Collection<String>> merge( | ||
| - StyleSpans<Collection<String>> spans, int len, String style ) { | ||
| - spans = spans.overlay( | ||
| - singleton( singletonList( style ), len ), | ||
| - ( bottomSpan, list ) -> { | ||
| - final List<String> l = | ||
| - new ArrayList<>( bottomSpan.size() + list.size() ); | ||
| - l.addAll( bottomSpan ); | ||
| - l.addAll( list ); | ||
| - return l; | ||
| - } ); | ||
| - | ||
| - return spans; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void unstylize( final String style ) { | ||
| - final var indexes = mStyles.remove( style ); | ||
| - if( indexes != null ) { | ||
| - mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | ||
| - } | ||
| - } | ||
| - | ||
| - @Override | ||
| - public Caret getCaret() { | ||
| - return mCaret; | ||
| - } | ||
| - | ||
| - private Caret createCaret( final StyleClassedTextArea editor ) { | ||
| - return Caret | ||
| - .builder() | ||
| - .with( Caret.Mutator::setEditor, editor ) | ||
| - .build(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * This method adds listeners to editor events. | ||
| - * | ||
| - * @param <T> The event type. | ||
| - * @param <U> The consumer type for the given event type. | ||
| - * @param event The event of interest. | ||
| - * @param consumer The method to call when the event happens. | ||
| - */ | ||
| - public <T extends Event, U extends T> void addEventListener( | ||
| - final EventPattern<? super T, ? extends U> event, | ||
| - final Consumer<? super U> consumer ) { | ||
| - Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | ||
| - } | ||
| - | ||
| - private void onEnterPressed( final KeyEvent ignored ) { | ||
| - final var currentLine = getCaretParagraph(); | ||
| - final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | ||
| - | ||
| - // By default, insert a new line by itself. | ||
| - String newText = NEWLINE; | ||
| - | ||
| - // If the pattern was matched then determine what block type to continue. | ||
| - if( matcher.matches() ) { | ||
| - if( matcher.group( 2 ).isEmpty() ) { | ||
| - final var pos = mTextArea.getCaretPosition(); | ||
| - mTextArea.selectRange( pos - currentLine.length(), pos ); | ||
| - } | ||
| - else { | ||
| - // Indent the new line with the same whitespace characters and | ||
| - // list markers as current line. This ensures that the indentation | ||
| - // is propagated. | ||
| - newText = newText.concat( matcher.group( 1 ) ); | ||
| - } | ||
| - } | ||
| - | ||
| - mTextArea.replaceSelection( newText ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * TODO: 105 - Insert key toggle overwrite (typeover) mode | ||
| - * | ||
| - * @param ignored Unused. | ||
| - */ | ||
| - private void onInsertPressed( final KeyEvent ignored ) { | ||
| - } | ||
| - | ||
| - private void cut( final KeyEvent event ) { | ||
| - cut(); | ||
| - } | ||
| - | ||
| - private void tab( final KeyEvent event ) { | ||
| - final var range = mTextArea.selectionProperty().getValue(); | ||
| - final var sb = new StringBuilder( 1024 ); | ||
| - | ||
| - if( range.getLength() > 0 ) { | ||
| - final var selection = mTextArea.getSelectedText(); | ||
| - | ||
| - selection.lines().forEach( | ||
| - ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | ||
| - ); | ||
| - } | ||
| - else { | ||
| - sb.append( "\t" ); | ||
| - } | ||
| - | ||
| - mTextArea.replaceSelection( sb.toString() ); | ||
| - } | ||
| - | ||
| - private void untab( final KeyEvent event ) { | ||
| - final var range = mTextArea.selectionProperty().getValue(); | ||
| - | ||
| - if( range.getLength() > 0 ) { | ||
| - final var selection = mTextArea.getSelectedText(); | ||
| - final var sb = new StringBuilder( selection.length() ); | ||
| - | ||
| - selection.lines().forEach( | ||
| - ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | ||
| - .append( NEWLINE ) | ||
| - ); | ||
| - | ||
| - mTextArea.replaceSelection( sb.toString() ); | ||
| - } | ||
| - else { | ||
| - final var p = getCaretParagraph(); | ||
| - | ||
| - if( p.startsWith( "\t" ) ) { | ||
| - mTextArea.selectParagraph(); | ||
| - mTextArea.replaceSelection( p.substring( 1 ) ); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Observers may listen for changes to the property returned from this method | ||
| - * to receive notifications when either the text or caret have changed. This | ||
| - * should not be used to track whether the text has been modified. | ||
| - */ | ||
| - public void addDirtyListener( ChangeListener<Boolean> listener ) { | ||
| - mDirty.addListener( listener ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Surrounds the selected text or word under the caret in Markdown markup. | ||
| - * | ||
| - * @param token The beginning and ending token for enclosing the text. | ||
| - */ | ||
| - private void enwrap( final String token ) { | ||
| - enwrap( token, token ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Surrounds the selected text or word under the caret in Markdown markup. | ||
| - * | ||
| - * @param began The beginning token for enclosing the text. | ||
| - * @param ended The ending token for enclosing the text. | ||
| - */ | ||
| - private void enwrap( final String began, String ended ) { | ||
| - // Ensure selected text takes precedence over the word at caret position. | ||
| - final var selected = mTextArea.selectionProperty().getValue(); | ||
| - final var range = selected.getLength() == 0 | ||
| - ? getCaretWord() | ||
| - : selected; | ||
| - String text = mTextArea.getText( range ); | ||
| - | ||
| - int length = range.getLength(); | ||
| - text = stripStart( text, null ); | ||
| - final int beganIndex = range.getStart() + (length - text.length()); | ||
| - | ||
| - length = text.length(); | ||
| - text = stripEnd( text, null ); | ||
| - final int endedIndex = range.getEnd() - (length - text.length()); | ||
| - | ||
| - mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Inserts the given block-level markup at the current caret position | ||
| - * within the document. This will prepend two blank lines to ensure that | ||
| - * the block element begins at the start of a new line. | ||
| - * | ||
| - * @param markup The text to insert at the caret. | ||
| - */ | ||
| - private void block( final String markup ) { | ||
| - final int pos = mTextArea.getCaretPosition(); | ||
| - mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the caret position within the current paragraph. | ||
| - * | ||
| - * @return A value from 0 to the length of the current paragraph. | ||
| - */ | ||
| - private int getCaretColumn() { | ||
| - return mTextArea.getCaretColumn(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public IndexRange getCaretWord() { | ||
| - final var paragraph = getCaretParagraph(); | ||
| - final var length = paragraph.length(); | ||
| - final var column = getCaretColumn(); | ||
| - | ||
| - var began = column; | ||
| - var ended = column; | ||
| - | ||
| - while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | ||
| - began--; | ||
| - } | ||
| - | ||
| - while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | ||
| - ended++; | ||
| - } | ||
| - | ||
| - final var iterator = BreakIterator.getWordInstance(); | ||
| - iterator.setText( paragraph ); | ||
| - | ||
| - while( began < length && iterator.isBoundary( began + 1 ) ) { | ||
| - began++; | ||
| - } | ||
| - | ||
| - while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | ||
| - ended--; | ||
| - } | ||
| - | ||
| - final var offset = getCaretDocumentOffset( column ); | ||
| - | ||
| - return IndexRange.normalize( began + offset, ended + offset ); | ||
| - } | ||
| - | ||
| - private int getCaretDocumentOffset( final int column ) { | ||
| - return mTextArea.getCaretPosition() - column; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the index of the paragraph where the caret resides. | ||
| - * | ||
| - * @return A number greater than or equal to 0. | ||
| - */ | ||
| - private int getCurrentParagraph() { | ||
| - return mTextArea.getCurrentParagraph(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the text for the paragraph that contains the caret. | ||
| - * | ||
| - * @return A non-null string, possibly empty. | ||
| - */ | ||
| - private String getCaretParagraph() { | ||
| - return getText( getCurrentParagraph() ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public String getText( final int paragraph ) { | ||
| - return mTextArea.getText( paragraph ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public String getText( final IndexRange indexes ) | ||
| - throws IndexOutOfBoundsException { | ||
| - return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void replaceText( final IndexRange indexes, final String s ) { | ||
| - mTextArea.replaceText( indexes, s ); | ||
| - } | ||
| - | ||
| - private UndoManager<?> getUndoManager() { | ||
| - return mTextArea.getUndoManager(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the path to a {@link Locale}-specific stylesheet. | ||
| - * | ||
| - * @return A non-null string to inject into the HTML document head. | ||
| - */ | ||
| - private static String getStylesheetPath( final Locale locale ) { | ||
| - return get( | ||
| - sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), | ||
| - locale.getLanguage(), | ||
| - locale.getScript(), | ||
| - locale.getCountry() | ||
| - ); | ||
| - } | ||
| - | ||
| - private Locale getLocale() { | ||
| - return localeProperty().toLocale(); | ||
| - } | ||
| - | ||
| - private LocaleProperty localeProperty() { | ||
| - return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Sets the font family name and font size at the same time. When the | ||
| - * workspace is loaded, the default font values are changed, which results | ||
| - * in this method being called. | ||
| - * | ||
| - * @param area Change the font settings for this text area. | ||
| - * @param name New font family name to apply. | ||
| - * @param points New font size to apply (in points, not pixels). | ||
| - */ | ||
| - private void setFont( | ||
| - final StyleClassedTextArea area, final String name, final double points ) { | ||
| - runLater( () -> area.setStyle( | ||
| - format( | ||
| - "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points ) | ||
| - ) | ||
| - ) ); | ||
| - } | ||
| - | ||
| - private String getFontName() { | ||
| - return fontNameProperty().get(); | ||
| - } | ||
| - | ||
| - private StringProperty fontNameProperty() { | ||
| - return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME ); | ||
| - } | ||
| - | ||
| - private double getFontSize() { | ||
| - return fontSizeProperty().get(); | ||
| - } | ||
| - | ||
| - private DoubleProperty fontSizeProperty() { | ||
| - return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Answers whether the given resource is of compatible {@link MediaType}s. | ||
| - * | ||
| - * @param mediaType The {@link MediaType} to compare. | ||
| - * @return {@code true} if the given {@link MediaType} is suitable for | ||
| - * editing with this type of editor. | ||
| - */ | ||
| - @Override | ||
| - public boolean supports( final MediaType mediaType ) { | ||
| - return isMediaType( mediaType ) || | ||
| - mediaType == TEXT_MARKDOWN || | ||
| - mediaType == TEXT_R_MARKDOWN || | ||
| - mediaType == TEXT_R_XML; | ||
| +import static com.keenwrite.io.MediaType.TEXT_MARKDOWN; | ||
| +import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; | ||
| +import static com.keenwrite.preferences.WorkspaceKeys.*; | ||
| +import static java.lang.Character.isWhitespace; | ||
| +import static java.lang.String.format; | ||
| +import static java.util.Collections.singletonList; | ||
| +import static javafx.application.Platform.runLater; | ||
| +import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | ||
| +import static javafx.scene.input.KeyCode.*; | ||
| +import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | ||
| +import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | ||
| +import static org.apache.commons.lang3.StringUtils.stripEnd; | ||
| +import static org.apache.commons.lang3.StringUtils.stripStart; | ||
| +import static org.fxmisc.richtext.model.StyleSpans.singleton; | ||
| +import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | ||
| +import static org.fxmisc.wellbehaved.event.InputMap.consume; | ||
| + | ||
| +/** | ||
| + * Responsible for editing Markdown documents. | ||
| + */ | ||
| +public final class MarkdownEditor extends BorderPane implements TextEditor { | ||
| + /** | ||
| + * Regular expression that matches the type of markup block. This is used | ||
| + * when Enter is pressed to continue the block environment. | ||
| + */ | ||
| + private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | ||
| + "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | ||
| + | ||
| + /** | ||
| + * The text editor. | ||
| + */ | ||
| + private final StyleClassedTextArea mTextArea = | ||
| + new StyleClassedTextArea( false ); | ||
| + | ||
| + /** | ||
| + * Wraps the text editor in scrollbars. | ||
| + */ | ||
| + private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | ||
| + new VirtualizedScrollPane<>( mTextArea ); | ||
| + | ||
| + private final Workspace mWorkspace; | ||
| + | ||
| + /** | ||
| + * Tracks where the caret is located in this document. This offers observable | ||
| + * properties for caret position changes. | ||
| + */ | ||
| + private final Caret mCaret = createCaret( mTextArea ); | ||
| + | ||
| + /** | ||
| + * File being edited by this editor instance. | ||
| + */ | ||
| + private File mFile; | ||
| + | ||
| + /** | ||
| + * Set to {@code true} upon text or caret position changes. Value is {@code | ||
| + * false} by default. | ||
| + */ | ||
| + private final BooleanProperty mDirty = new SimpleBooleanProperty(); | ||
| + | ||
| + /** | ||
| + * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | ||
| + * either no encoding could be determined or this is a new (empty) file. | ||
| + */ | ||
| + private final Charset mEncoding; | ||
| + | ||
| + /** | ||
| + * Tracks whether the in-memory definitions have changed with respect to the | ||
| + * persisted definitions. | ||
| + */ | ||
| + private final BooleanProperty mModified = new SimpleBooleanProperty(); | ||
| + | ||
| + public MarkdownEditor( final Workspace workspace ) { | ||
| + this( DOCUMENT_DEFAULT, workspace ); | ||
| + } | ||
| + | ||
| + public MarkdownEditor( final File file, final Workspace workspace ) { | ||
| + mEncoding = open( mFile = file ); | ||
| + mWorkspace = workspace; | ||
| + | ||
| + initTextArea( mTextArea ); | ||
| + initStyle( mTextArea ); | ||
| + initScrollPane( mScrollPane ); | ||
| + initSpellchecker( mTextArea ); | ||
| + initHotKeys(); | ||
| + initUndoManager(); | ||
| + } | ||
| + | ||
| + private void initTextArea( final StyleClassedTextArea textArea ) { | ||
| + textArea.setWrapText( true ); | ||
| + textArea.requestFollowCaret(); | ||
| + textArea.moveTo( 0 ); | ||
| + | ||
| + textArea.textProperty().addListener( ( c, o, n ) -> { | ||
| + // Fire, regardless of whether the caret position has changed. | ||
| + mDirty.set( false ); | ||
| + | ||
| + // Prevent a caret position change from raising the dirty bits. | ||
| + mDirty.set( true ); | ||
| + } ); | ||
| + | ||
| + textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | ||
| + // Fire when the caret position has changed and the text has not. | ||
| + mDirty.set( true ); | ||
| + mDirty.set( false ); | ||
| + } ); | ||
| + | ||
| + textArea.focusedProperty().addListener( ( c, o, n ) -> { | ||
| + if( n != null && n ) { | ||
| + fireTextEditorFocus( this ); | ||
| + } | ||
| + } ); | ||
| + } | ||
| + | ||
| + private void initStyle( final StyleClassedTextArea textArea ) { | ||
| + textArea.getStyleClass().add( "markdown" ); | ||
| + | ||
| + final var stylesheets = textArea.getStylesheets(); | ||
| + stylesheets.add( getStylesheetPath( getLocale() ) ); | ||
| + | ||
| + localeProperty().addListener( ( c, o, n ) -> { | ||
| + if( n != null ) { | ||
| + stylesheets.clear(); | ||
| + stylesheets.add( getStylesheetPath( getLocale() ) ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + fontNameProperty().addListener( | ||
| + ( c, o, n ) -> | ||
| + setFont( mTextArea, getFontName(), getFontSize() ) | ||
| + ); | ||
| + | ||
| + fontSizeProperty().addListener( | ||
| + ( c, o, n ) -> | ||
| + setFont( mTextArea, getFontName(), getFontSize() ) | ||
| + ); | ||
| + | ||
| + setFont( mTextArea, getFontName(), getFontSize() ); | ||
| + } | ||
| + | ||
| + private void initScrollPane( | ||
| + final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | ||
| + scrollpane.setVbarPolicy( ALWAYS ); | ||
| + setCenter( scrollpane ); | ||
| + } | ||
| + | ||
| + private void initSpellchecker( final StyleClassedTextArea textarea ) { | ||
| + final var speller = new TextEditorSpeller(); | ||
| + speller.checkDocument( textarea ); | ||
| + speller.checkParagraphs( textarea ); | ||
| + } | ||
| + | ||
| + private void initHotKeys() { | ||
| + addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | ||
| + addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | ||
| + addEventListener( keyPressed( TAB ), this::tab ); | ||
| + addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | ||
| + addEventListener( keyPressed( INSERT ), this::onInsertPressed ); | ||
| + } | ||
| + | ||
| + private void initUndoManager() { | ||
| + final var undoManager = getUndoManager(); | ||
| + final var markedPosition = undoManager.atMarkedPositionProperty(); | ||
| + | ||
| + undoManager.forgetHistory(); | ||
| + undoManager.mark(); | ||
| + mModified.bind( Bindings.not( markedPosition ) ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void moveTo( final int offset ) { | ||
| + assert 0 <= offset && offset <= mTextArea.getLength(); | ||
| + mTextArea.moveTo( offset ); | ||
| + mTextArea.requestFollowCaret(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Delegate the focus request to the text area itself. | ||
| + */ | ||
| + @Override | ||
| + public void requestFocus() { | ||
| + mTextArea.requestFocus(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void setText( final String text ) { | ||
| + mTextArea.clear(); | ||
| + mTextArea.appendText( text ); | ||
| + mTextArea.getUndoManager().mark(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public String getText() { | ||
| + return mTextArea.getText(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public Charset getEncoding() { | ||
| + return mEncoding; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public File getFile() { | ||
| + return mFile; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void rename( final File file ) { | ||
| + mFile = file; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void undo() { | ||
| + final var manager = getUndoManager(); | ||
| + xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void redo() { | ||
| + final var manager = getUndoManager(); | ||
| + xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Performs an undo or redo action, if possible, otherwise displays an error | ||
| + * message to the user. | ||
| + * | ||
| + * @param ready Answers whether the action can be executed. | ||
| + * @param action The action to execute. | ||
| + * @param key The informational message key having a value to display if | ||
| + * the {@link Supplier} is not ready. | ||
| + */ | ||
| + private void xxdo( | ||
| + final Supplier<Boolean> ready, final Runnable action, final String key ) { | ||
| + if( ready.get() ) { | ||
| + action.run(); | ||
| + } | ||
| + else { | ||
| + clue( key ); | ||
| + } | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void cut() { | ||
| + final var selected = mTextArea.getSelectedText(); | ||
| + | ||
| + // Emulate selecting the current line by firing Home then Shift+Down Arrow. | ||
| + if( selected == null || selected.isEmpty() ) { | ||
| + // Note: mTextArea.selectLine() does not select empty lines. | ||
| + mTextArea.fireEvent( keyDown( HOME, false ) ); | ||
| + mTextArea.fireEvent( keyDown( DOWN, true ) ); | ||
| + } | ||
| + | ||
| + mTextArea.cut(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void copy() { | ||
| + mTextArea.copy(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void paste() { | ||
| + mTextArea.paste(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void selectAll() { | ||
| + mTextArea.selectAll(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void bold() { | ||
| + enwrap( "**" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void italic() { | ||
| + enwrap( "*" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void monospace() { | ||
| + enwrap( "`" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void superscript() { | ||
| + enwrap( "^" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void subscript() { | ||
| + enwrap( "~" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void strikethrough() { | ||
| + enwrap( "~~" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void blockquote() { | ||
| + block( "> " ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void code() { | ||
| + enwrap( "`" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void fencedCodeBlock() { | ||
| + enwrap( "\n\n```\n", "\n```\n\n" ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void heading( final int level ) { | ||
| + final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | ||
| + block( format( "%s ", hashes ) ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void unorderedList() { | ||
| + block( "* " ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void orderedList() { | ||
| + block( "1. " ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void horizontalRule() { | ||
| + block( format( "---%n%n" ) ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public Node getNode() { | ||
| + return this; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public ReadOnlyBooleanProperty modifiedProperty() { | ||
| + return mModified; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void clearModifiedProperty() { | ||
| + getUndoManager().mark(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | ||
| + return mScrollPane; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public StyleClassedTextArea getTextArea() { | ||
| + return mTextArea; | ||
| + } | ||
| + | ||
| + private final Map<String, IndexRange> mStyles = new HashMap<>(); | ||
| + | ||
| + @Override | ||
| + public void stylize( final IndexRange range, final String style ) { | ||
| + final var began = range.getStart(); | ||
| + final var ended = range.getEnd() + 1; | ||
| + | ||
| + assert 0 <= began && began <= ended; | ||
| + assert style != null; | ||
| + | ||
| + // TODO: Ensure spell check and find highlights can coexist. | ||
| +// final var spans = mTextArea.getStyleSpans( range ); | ||
| +// System.out.println( "SPANS: " + spans ); | ||
| + | ||
| +// final var spans = mTextArea.getStyleSpans( range ); | ||
| +// mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | ||
| +// ) ); | ||
| + | ||
| +// final var builder = new StyleSpansBuilder<Collection<String>>(); | ||
| +// builder.add( singleton( style ), range.getLength() + 1 ); | ||
| +// mTextArea.setStyleSpans( began, builder.create() ); | ||
| + | ||
| +// final var s = mTextArea.getStyleSpans( began, ended ); | ||
| +// System.out.println( "STYLES: " +s ); | ||
| + | ||
| + mStyles.put( style, range ); | ||
| + mTextArea.setStyleClass( began, ended, style ); | ||
| + | ||
| + // Ensure that whenever the user interacts with the text that the found | ||
| + // word will have its highlighting removed. The handler removes itself. | ||
| + // This won't remove the highlighting if the caret position moves by mouse. | ||
| + final var handler = mTextArea.getOnKeyPressed(); | ||
| + mTextArea.setOnKeyPressed( ( event ) -> { | ||
| + mTextArea.setOnKeyPressed( handler ); | ||
| + unstylize( style ); | ||
| + } ); | ||
| + | ||
| + //mTextArea.setStyleSpans(began, ended, s); | ||
| + } | ||
| + | ||
| + private static StyleSpans<Collection<String>> merge( | ||
| + StyleSpans<Collection<String>> spans, int len, String style ) { | ||
| + spans = spans.overlay( | ||
| + singleton( singletonList( style ), len ), | ||
| + ( bottomSpan, list ) -> { | ||
| + final List<String> l = | ||
| + new ArrayList<>( bottomSpan.size() + list.size() ); | ||
| + l.addAll( bottomSpan ); | ||
| + l.addAll( list ); | ||
| + return l; | ||
| + } ); | ||
| + | ||
| + return spans; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void unstylize( final String style ) { | ||
| + final var indexes = mStyles.remove( style ); | ||
| + if( indexes != null ) { | ||
| + mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | ||
| + } | ||
| + } | ||
| + | ||
| + @Override | ||
| + public Caret getCaret() { | ||
| + return mCaret; | ||
| + } | ||
| + | ||
| + private Caret createCaret( final StyleClassedTextArea editor ) { | ||
| + return Caret | ||
| + .builder() | ||
| + .with( Caret.Mutator::setEditor, editor ) | ||
| + .build(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * This method adds listeners to editor events. | ||
| + * | ||
| + * @param <T> The event type. | ||
| + * @param <U> The consumer type for the given event type. | ||
| + * @param event The event of interest. | ||
| + * @param consumer The method to call when the event happens. | ||
| + */ | ||
| + public <T extends Event, U extends T> void addEventListener( | ||
| + final EventPattern<? super T, ? extends U> event, | ||
| + final Consumer<? super U> consumer ) { | ||
| + Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | ||
| + } | ||
| + | ||
| + private void onEnterPressed( final KeyEvent ignored ) { | ||
| + final var currentLine = getCaretParagraph(); | ||
| + final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | ||
| + | ||
| + // By default, insert a new line by itself. | ||
| + String newText = NEWLINE; | ||
| + | ||
| + // If the pattern was matched then determine what block type to continue. | ||
| + if( matcher.matches() ) { | ||
| + if( matcher.group( 2 ).isEmpty() ) { | ||
| + final var pos = mTextArea.getCaretPosition(); | ||
| + mTextArea.selectRange( pos - currentLine.length(), pos ); | ||
| + } | ||
| + else { | ||
| + // Indent the new line with the same whitespace characters and | ||
| + // list markers as current line. This ensures that the indentation | ||
| + // is propagated. | ||
| + newText = newText.concat( matcher.group( 1 ) ); | ||
| + } | ||
| + } | ||
| + | ||
| + mTextArea.replaceSelection( newText ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * TODO: 105 - Insert key toggle overwrite (typeover) mode | ||
| + * | ||
| + * @param ignored Unused. | ||
| + */ | ||
| + private void onInsertPressed( final KeyEvent ignored ) { | ||
| + } | ||
| + | ||
| + private void cut( final KeyEvent event ) { | ||
| + cut(); | ||
| + } | ||
| + | ||
| + private void tab( final KeyEvent event ) { | ||
| + final var range = mTextArea.selectionProperty().getValue(); | ||
| + final var sb = new StringBuilder( 1024 ); | ||
| + | ||
| + if( range.getLength() > 0 ) { | ||
| + final var selection = mTextArea.getSelectedText(); | ||
| + | ||
| + selection.lines().forEach( | ||
| + ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | ||
| + ); | ||
| + } | ||
| + else { | ||
| + sb.append( "\t" ); | ||
| + } | ||
| + | ||
| + mTextArea.replaceSelection( sb.toString() ); | ||
| + } | ||
| + | ||
| + private void untab( final KeyEvent event ) { | ||
| + final var range = mTextArea.selectionProperty().getValue(); | ||
| + | ||
| + if( range.getLength() > 0 ) { | ||
| + final var selection = mTextArea.getSelectedText(); | ||
| + final var sb = new StringBuilder( selection.length() ); | ||
| + | ||
| + selection.lines().forEach( | ||
| + ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | ||
| + .append( NEWLINE ) | ||
| + ); | ||
| + | ||
| + mTextArea.replaceSelection( sb.toString() ); | ||
| + } | ||
| + else { | ||
| + final var p = getCaretParagraph(); | ||
| + | ||
| + if( p.startsWith( "\t" ) ) { | ||
| + mTextArea.selectParagraph(); | ||
| + mTextArea.replaceSelection( p.substring( 1 ) ); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Observers may listen for changes to the property returned from this method | ||
| + * to receive notifications when either the text or caret have changed. This | ||
| + * should not be used to track whether the text has been modified. | ||
| + */ | ||
| + public void addDirtyListener( ChangeListener<Boolean> listener ) { | ||
| + mDirty.addListener( listener ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Surrounds the selected text or word under the caret in Markdown markup. | ||
| + * | ||
| + * @param token The beginning and ending token for enclosing the text. | ||
| + */ | ||
| + private void enwrap( final String token ) { | ||
| + enwrap( token, token ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Surrounds the selected text or word under the caret in Markdown markup. | ||
| + * | ||
| + * @param began The beginning token for enclosing the text. | ||
| + * @param ended The ending token for enclosing the text. | ||
| + */ | ||
| + private void enwrap( final String began, String ended ) { | ||
| + // Ensure selected text takes precedence over the word at caret position. | ||
| + final var selected = mTextArea.selectionProperty().getValue(); | ||
| + final var range = selected.getLength() == 0 | ||
| + ? getCaretWord() | ||
| + : selected; | ||
| + String text = mTextArea.getText( range ); | ||
| + | ||
| + int length = range.getLength(); | ||
| + text = stripStart( text, null ); | ||
| + final int beganIndex = range.getStart() + (length - text.length()); | ||
| + | ||
| + length = text.length(); | ||
| + text = stripEnd( text, null ); | ||
| + final int endedIndex = range.getEnd() - (length - text.length()); | ||
| + | ||
| + mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Inserts the given block-level markup at the current caret position | ||
| + * within the document. This will prepend two blank lines to ensure that | ||
| + * the block element begins at the start of a new line. | ||
| + * | ||
| + * @param markup The text to insert at the caret. | ||
| + */ | ||
| + private void block( final String markup ) { | ||
| + final int pos = mTextArea.getCaretPosition(); | ||
| + mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the caret position within the current paragraph. | ||
| + * | ||
| + * @return A value from 0 to the length of the current paragraph. | ||
| + */ | ||
| + private int getCaretColumn() { | ||
| + return mTextArea.getCaretColumn(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public IndexRange getCaretWord() { | ||
| + final var paragraph = getCaretParagraph(); | ||
| + final var length = paragraph.length(); | ||
| + final var column = getCaretColumn(); | ||
| + | ||
| + var began = column; | ||
| + var ended = column; | ||
| + | ||
| + while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | ||
| + began--; | ||
| + } | ||
| + | ||
| + while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | ||
| + ended++; | ||
| + } | ||
| + | ||
| + final var iterator = BreakIterator.getWordInstance(); | ||
| + iterator.setText( paragraph ); | ||
| + | ||
| + while( began < length && iterator.isBoundary( began + 1 ) ) { | ||
| + began++; | ||
| + } | ||
| + | ||
| + while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | ||
| + ended--; | ||
| + } | ||
| + | ||
| + final var offset = getCaretDocumentOffset( column ); | ||
| + | ||
| + return IndexRange.normalize( began + offset, ended + offset ); | ||
| + } | ||
| + | ||
| + private int getCaretDocumentOffset( final int column ) { | ||
| + return mTextArea.getCaretPosition() - column; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the index of the paragraph where the caret resides. | ||
| + * | ||
| + * @return A number greater than or equal to 0. | ||
| + */ | ||
| + private int getCurrentParagraph() { | ||
| + return mTextArea.getCurrentParagraph(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the text for the paragraph that contains the caret. | ||
| + * | ||
| + * @return A non-null string, possibly empty. | ||
| + */ | ||
| + private String getCaretParagraph() { | ||
| + return getText( getCurrentParagraph() ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public String getText( final int paragraph ) { | ||
| + return mTextArea.getText( paragraph ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public String getText( final IndexRange indexes ) | ||
| + throws IndexOutOfBoundsException { | ||
| + return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void replaceText( final IndexRange indexes, final String s ) { | ||
| + mTextArea.replaceText( indexes, s ); | ||
| + } | ||
| + | ||
| + private UndoManager<?> getUndoManager() { | ||
| + return mTextArea.getUndoManager(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the path to a {@link Locale}-specific stylesheet. | ||
| + * | ||
| + * @return A non-null string to inject into the HTML document head. | ||
| + */ | ||
| + private static String getStylesheetPath( final Locale locale ) { | ||
| + return get( | ||
| + sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), | ||
| + locale.getLanguage(), | ||
| + locale.getScript(), | ||
| + locale.getCountry() | ||
| + ); | ||
| + } | ||
| + | ||
| + private Locale getLocale() { | ||
| + return localeProperty().toLocale(); | ||
| + } | ||
| + | ||
| + private LocaleProperty localeProperty() { | ||
| + return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Sets the font family name and font size at the same time. When the | ||
| + * workspace is loaded, the default font values are changed, which results | ||
| + * in this method being called. | ||
| + * | ||
| + * @param area Change the font settings for this text area. | ||
| + * @param name New font family name to apply. | ||
| + * @param points New font size to apply (in points, not pixels). | ||
| + */ | ||
| + private void setFont( | ||
| + final StyleClassedTextArea area, final String name, final double points ) { | ||
| + runLater( () -> area.setStyle( | ||
| + format( | ||
| + "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points ) | ||
| + ) | ||
| + ) ); | ||
| + } | ||
| + | ||
| + private String getFontName() { | ||
| + return fontNameProperty().get(); | ||
| + } | ||
| + | ||
| + private StringProperty fontNameProperty() { | ||
| + return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME ); | ||
| + } | ||
| + | ||
| + private double getFontSize() { | ||
| + return fontSizeProperty().get(); | ||
| + } | ||
| + | ||
| + private DoubleProperty fontSizeProperty() { | ||
| + return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Answers whether the given resource is of compatible {@link MediaType}s. | ||
| + * | ||
| + * @param mediaType The {@link MediaType} to compare. | ||
| + * @return {@code true} if the given {@link MediaType} is suitable for | ||
| + * editing with this type of editor. | ||
| + */ | ||
| + @Override | ||
| + public boolean supports( final MediaType mediaType ) { | ||
| + return isMediaType( mediaType ) || | ||
| + mediaType == TEXT_MARKDOWN || | ||
| + mediaType == TEXT_R_MARKDOWN; | ||
| } | ||
| } |
| SOURCE( "source" ), | ||
| DEFINITION( "definition" ), | ||
| - XML( "xml" ), | ||
| CSV( "csv" ), | ||
| JSON( "json" ), |
| TEXT_PLAIN( TEXT, "plain" ), | ||
| TEXT_R_MARKDOWN( TEXT, "R+markdown" ), | ||
| - TEXT_R_XML( TEXT, "R+xml" ), | ||
| TEXT_XHTML( TEXT, "xhtml+xml" ), | ||
| TEXT_XML( TEXT, "xml" ), |
| MEDIA_TEXT_PLAIN( TEXT_PLAIN, of( "txt", "asc", "ascii", "text", "utxt" ) ), | ||
| MEDIA_TEXT_R_MARKDOWN( TEXT_R_MARKDOWN, of( "Rmd" ) ), | ||
| - MEDIA_TEXT_R_XML( TEXT_R_XML, of( "Rxml" ) ), | ||
| MEDIA_TEXT_XHTML( TEXT_XHTML, of( "xhtml" ) ), | ||
| MEDIA_TEXT_XML( TEXT_XML ), |
| } | ||
| + /** | ||
| + * Calculates scaled dimensions while maintaining the image aspect ratio. | ||
| + */ | ||
| private Dimension rescaleDimensions( | ||
| final BufferedImage bi, final int width, final int height ) { |
| import java.util.Map; | ||
| +import java.util.function.Function; | ||
| import static com.keenwrite.processors.text.TextReplacementFactory.replace; | ||
| /** | ||
| * Processes interpolated string definitions in the document and inserts | ||
| * their values into the post-processed text. The default variable syntax is | ||
| * {@code $variable$}. | ||
| */ | ||
| -public class DefinitionProcessor extends ExecutorProcessor<String> { | ||
| +public class DefinitionProcessor | ||
| + extends ExecutorProcessor<String> implements Function<String, String> { | ||
| private final Map<String, String> mDefinitions; |
| import com.keenwrite.Caret; | ||
| -import com.keenwrite.constants.Constants; | ||
| import com.keenwrite.ExportFormat; | ||
| +import com.keenwrite.constants.Constants; | ||
| import com.keenwrite.io.FileType; | ||
| import com.keenwrite.preferences.Workspace; |
| final var processor = switch( context.getFileType() ) { | ||
| case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor ); | ||
| - case RXML -> createRXmlProcessor( successor ); | ||
| - case XML -> createXmlProcessor( successor ); | ||
| default -> createPreformattedProcessor( successor ); | ||
| }; | ||
| final Processor<String> successor ) { | ||
| return new DefinitionProcessor( successor, getProcessorContext() ); | ||
| - } | ||
| - | ||
| - protected Processor<String> createRXmlProcessor( | ||
| - final Processor<String> successor ) { | ||
| - final var context = getProcessorContext(); | ||
| - final var rp = MarkdownProcessor.create( successor, context ); | ||
| - return new XmlProcessor( rp, context ); | ||
| - } | ||
| - | ||
| - private Processor<String> createXmlProcessor( | ||
| - final Processor<String> successor ) { | ||
| - final var xmlp = new XmlProcessor( successor, getProcessorContext() ); | ||
| - return createDefinitionProcessor( xmlp ); | ||
| } | ||
| -/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | ||
| -package com.keenwrite.processors; | ||
| - | ||
| -import net.sf.saxon.Configuration; | ||
| -import net.sf.saxon.TransformerFactoryImpl; | ||
| -import net.sf.saxon.om.IgnorableSpaceStrippingRule; | ||
| -import net.sf.saxon.trans.XPathException; | ||
| - | ||
| -import javax.xml.stream.XMLEventReader; | ||
| -import javax.xml.stream.XMLInputFactory; | ||
| -import javax.xml.stream.XMLStreamException; | ||
| -import javax.xml.stream.events.ProcessingInstruction; | ||
| -import javax.xml.transform.*; | ||
| -import javax.xml.transform.stream.StreamResult; | ||
| -import javax.xml.transform.stream.StreamSource; | ||
| -import java.io.Reader; | ||
| -import java.io.StringReader; | ||
| -import java.io.StringWriter; | ||
| -import java.nio.file.Path; | ||
| -import java.nio.file.Paths; | ||
| - | ||
| -import static com.keenwrite.events.StatusEvent.clue; | ||
| -import static javax.xml.stream.XMLInputFactory.newInstance; | ||
| -import static net.sf.saxon.tree.util.ProcInstParser.getPseudoAttribute; | ||
| - | ||
| -/** | ||
| - * Transforms an XML document. The XML document must have a stylesheet specified | ||
| - * as part of its processing instructions, such as: | ||
| - * <p> | ||
| - * {@code xml-stylesheet type="text/xsl" href="markdown.xsl"} | ||
| - * </p> | ||
| - * <p> | ||
| - * The XSL must transform the XML document into Markdown, or another format | ||
| - * recognized by the next link on the chain. | ||
| - * </p> | ||
| - */ | ||
| -public final class XmlProcessor extends ExecutorProcessor<String> | ||
| - implements ErrorListener { | ||
| - | ||
| - private final XMLInputFactory mXmlInputFactory = newInstance(); | ||
| - private final Configuration mConfiguration = new Configuration(); | ||
| - private final TransformerFactory mTransformerFactory = | ||
| - new TransformerFactoryImpl(mConfiguration); | ||
| - private Transformer mTransformer; | ||
| - | ||
| - private final Path mPath; | ||
| - | ||
| - /** | ||
| - * Constructs an XML processor that can transform an XML document into another | ||
| - * format based on the XSL file specified as a processing instruction. The | ||
| - * path must point to the directory where the XSL file is found, which implies | ||
| - * that they must be in the same directory. | ||
| - * | ||
| - * @param successor Next link in the processing chain. | ||
| - * @param context Contains path to the XML file content to be processed. | ||
| - */ | ||
| - public XmlProcessor( | ||
| - final Processor<String> successor, | ||
| - final ProcessorContext context ) { | ||
| - super( successor ); | ||
| - mPath = context.getDocumentPath(); | ||
| - | ||
| - // Bubble problems up to the user interface, rather than standard error. | ||
| - mTransformerFactory.setErrorListener( this ); | ||
| - final var options = mConfiguration.getParseOptions(); | ||
| - options.setSpaceStrippingRule( IgnorableSpaceStrippingRule.getInstance() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Transforms the given XML text into another form (typically Markdown). | ||
| - * | ||
| - * @param text The text to transform, can be empty, cannot be null. | ||
| - * @return The transformed text, or empty if text is empty. | ||
| - */ | ||
| - @Override | ||
| - public String apply( final String text ) { | ||
| - try { | ||
| - return text.isEmpty() ? text : transform( text ); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - throw new RuntimeException( ex ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Performs an XSL transformation on the given XML text. The XML text must | ||
| - * have a processing instruction that points to the XSL template file to use | ||
| - * for the transformation. | ||
| - * | ||
| - * @param text The text to transform. | ||
| - * @return The transformed text. | ||
| - */ | ||
| - private String transform( final String text ) throws Exception { | ||
| - try( | ||
| - final var output = new StringWriter( text.length() ); | ||
| - final var input = new StringReader( text ) ) { | ||
| - // Extract the XML stylesheet processing instruction. | ||
| - final var template = getXsltFilename( text ); | ||
| - final var xsl = getXslPath( template ); | ||
| - | ||
| - // TODO: Use FileWatchService | ||
| - // Listen for external file modification events. | ||
| - // mSnitch.listen( xsl ); | ||
| - | ||
| - getTransformer( xsl ).transform( | ||
| - new StreamSource( input ), | ||
| - new StreamResult( output ) | ||
| - ); | ||
| - | ||
| - return output.toString(); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns an XSL transformer ready to transform an XML document using the | ||
| - * XSLT file specified by the given path. If the path is already known then | ||
| - * this will return the associated transformer. | ||
| - * | ||
| - * @param xsl The path to an XSLT file. | ||
| - * @return Transformer that transforms XML documents using said XSLT file. | ||
| - * @throws TransformerConfigurationException Instantiate transformer failed. | ||
| - */ | ||
| - private synchronized Transformer getTransformer( final Path xsl ) | ||
| - throws TransformerConfigurationException { | ||
| - if( mTransformer == null ) { | ||
| - mTransformer = createTransformer( xsl ); | ||
| - } | ||
| - | ||
| - return mTransformer; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a configured transformer ready to run. | ||
| - * | ||
| - * @param xsl The stylesheet to use for transforming XML documents. | ||
| - * @return XML document transformed into another format (usually Markdown). | ||
| - * @throws TransformerConfigurationException Could not create the transformer. | ||
| - */ | ||
| - protected Transformer createTransformer( final Path xsl ) | ||
| - throws TransformerConfigurationException { | ||
| - final var xslt = new StreamSource( xsl.toFile() ); | ||
| - | ||
| - return getTransformerFactory().newTransformer( xslt ); | ||
| - } | ||
| - | ||
| - private Path getXslPath( final String filename ) { | ||
| - final var xmlDirectory = mPath.toFile().getParentFile(); | ||
| - | ||
| - return Paths.get( xmlDirectory.getPath(), filename ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Given XML text, this will use a StAX pull reader to obtain the XML | ||
| - * stylesheet processing instruction. This will throw a parse exception if the | ||
| - * href pseudo-attribute file name value cannot be found. | ||
| - * | ||
| - * @param xml The XML containing an xml-stylesheet processing instruction. | ||
| - * @return The href pseudo-attribute value. | ||
| - * @throws XMLStreamException Could not parse the XML file. | ||
| - */ | ||
| - private String getXsltFilename( final String xml ) | ||
| - throws XMLStreamException, XPathException { | ||
| - var result = ""; | ||
| - | ||
| - try( final var sr = new StringReader( xml ) ) { | ||
| - final var reader = createXmlEventReader( sr ); | ||
| - var found = false; | ||
| - var count = 0; | ||
| - | ||
| - // If the processing instruction wasn't found in the first 10 lines, | ||
| - // fail fast. This should iterate twice through the loop. | ||
| - while( !found && reader.hasNext() && count++ < 10 ) { | ||
| - final var event = reader.nextEvent(); | ||
| - | ||
| - if( event.isProcessingInstruction() ) { | ||
| - final var pi = (ProcessingInstruction) event; | ||
| - final var target = pi.getTarget(); | ||
| - | ||
| - if( "xml-stylesheet".equalsIgnoreCase( target ) ) { | ||
| - result = getPseudoAttribute( pi.getData(), "href" ); | ||
| - found = true; | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return result; | ||
| - } | ||
| - | ||
| - private XMLEventReader createXmlEventReader( final Reader reader ) | ||
| - throws XMLStreamException { | ||
| - return mXmlInputFactory.createXMLEventReader( reader ); | ||
| - } | ||
| - | ||
| - private synchronized TransformerFactory getTransformerFactory() { | ||
| - return mTransformerFactory; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Called when the XSL transformer issues a warning. | ||
| - * | ||
| - * @param ex The problem the transformer encountered. | ||
| - */ | ||
| - @Override | ||
| - public void warning( final TransformerException ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Called when the XSL transformer issues an error. | ||
| - * | ||
| - * @param ex The problem the transformer encountered. | ||
| - */ | ||
| - @Override | ||
| - public void error( final TransformerException ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Called when the XSL transformer issues a fatal error, which is probably | ||
| - * a bit over-dramatic for a method name. | ||
| - * | ||
| - * @param ex The problem the transformer encountered. | ||
| - */ | ||
| - @Override | ||
| - public void fatalError( final TransformerException ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| -} | ||
| import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; | ||
| -import static com.keenwrite.io.MediaType.TEXT_R_XML; | ||
| import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | ||
| final List<Extension> extensions = new ArrayList<>(); | ||
| - if( mediaType == TEXT_R_MARKDOWN || mediaType == TEXT_R_XML ) { | ||
| + if( mediaType == TEXT_R_MARKDOWN ) { | ||
| final var rProcessor = new RProcessor( context ); | ||
| extensions.add( RExtension.create( rProcessor, context ) ); | ||
| @Override | ||
| - public void extend( | ||
| - final Builder builder, @NotNull final String rendererType ) { | ||
| + public void extend( @NotNull final Builder builder, | ||
| + @NotNull final String rendererType ) { | ||
| builder.attributeProviderFactory( | ||
| IdAttributeProvider.createFactory( mCaret ) ); |
| @Override | ||
| - public void extend( | ||
| - @NotNull final Builder builder, @NotNull final String rendererType ) { | ||
| + public void extend( @NotNull final Builder builder, | ||
| + @NotNull final String rendererType ) { | ||
| builder.linkResolverFactory( new ResolverFactory() ); | ||
| } |
| import com.keenwrite.processors.ProcessorContext; | ||
| +import java.util.function.Function; | ||
| + | ||
| import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | ||
| /** | ||
| * Responsible for processing R statements within a text block. | ||
| */ | ||
| -public final class RProcessor extends ExecutorProcessor<String> { | ||
| +public final class RProcessor | ||
| + extends ExecutorProcessor<String> implements Function<String, String> { | ||
| private final Processor<String> mProcessor; | ||
| private final InlineRProcessor mInlineRProcessor; |
| private static final SpellChecker sSpellChecker = forLexicon( "en.txt" ); | ||
| + private final Parser mParser; | ||
| + | ||
| public TextEditorSpeller() { | ||
| + mParser = Parser.builder().build(); | ||
| } | ||
| } | ||
| } | ||
| - | ||
| - /** | ||
| - * TODO: #59 -- Replace using Markdown processor instantiated for Markdown | ||
| - * files. | ||
| - */ | ||
| - private final Parser mParser = Parser.builder().build(); | ||
| /** | ||
| -/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | ||
| -package com.keenwrite.ui.actions; | ||
| - | ||
| -import com.keenwrite.Messages; | ||
| -import com.keenwrite.io.FileType; | ||
| -import com.keenwrite.service.Settings; | ||
| -import javafx.beans.property.Property; | ||
| -import javafx.stage.FileChooser; | ||
| -import javafx.stage.FileChooser.ExtensionFilter; | ||
| -import javafx.stage.Window; | ||
| - | ||
| -import java.io.File; | ||
| -import java.util.ArrayList; | ||
| -import java.util.List; | ||
| -import java.util.Optional; | ||
| - | ||
| -import static com.keenwrite.constants.Constants.*; | ||
| -import static com.keenwrite.Messages.get; | ||
| -import static com.keenwrite.io.FileType.*; | ||
| -import static java.lang.String.format; | ||
| - | ||
| -/** | ||
| - * Responsible for opening a dialog that provides users with the ability to | ||
| - * select files. | ||
| - */ | ||
| -public final class FileChooserCommand { | ||
| - private static final String FILTER_EXTENSION_TITLES = | ||
| - "Dialog.file.choose.filter"; | ||
| - | ||
| - /** | ||
| - * Dialog owner. | ||
| - */ | ||
| - private final Window mParent; | ||
| - | ||
| - /** | ||
| - * Set to the directory of most recently selected file. | ||
| - */ | ||
| - private final Property<File> mDirectory; | ||
| - | ||
| - /** | ||
| - * Constructs a new {@link FileChooserCommand} that will attach to a given | ||
| - * parent window and update the given property upon a successful selection. | ||
| - * | ||
| - * @param parent The parent window that will own the dialog. | ||
| - * @param directory The most recently opened file's directory property. | ||
| - */ | ||
| - public FileChooserCommand( | ||
| - final Window parent, final Property<File> directory ) { | ||
| - mParent = parent; | ||
| - mDirectory = directory; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns a list of files to be opened. | ||
| - * | ||
| - * @return A non-null, possibly empty list of files to open. | ||
| - */ | ||
| - public List<File> openFiles() { | ||
| - final var dialog = createFileChooser( | ||
| - "Dialog.file.choose.open.title" ); | ||
| - final var list = dialog.showOpenMultipleDialog( mParent ); | ||
| - final List<File> selected = list == null ? List.of() : list; | ||
| - final var files = new ArrayList<File>( selected.size() ); | ||
| - | ||
| - files.addAll( selected ); | ||
| - | ||
| - if( !files.isEmpty() ) { | ||
| - setRecentDirectory( files.get( 0 ) ); | ||
| - } | ||
| - | ||
| - return files; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Allows saving the document under a new file name. | ||
| - * | ||
| - * @return The new file name. | ||
| - */ | ||
| - public Optional<File> saveAs() { | ||
| - final var dialog = createFileChooser( "Dialog.file.choose.save.title" ); | ||
| - return saveOrExportAs( dialog ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Allows exporting the document to a new file format. | ||
| - * | ||
| - * @return The file name for exporting into. | ||
| - */ | ||
| - public Optional<File> exportAs( final File filename ) { | ||
| - final var dialog = createFileChooser( "Dialog.file.choose.export.title" ); | ||
| - dialog.setInitialFileName( filename.getName() ); | ||
| - return saveOrExportAs( dialog ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Helper method called when saving or exporting. | ||
| - * | ||
| - * @param dialog The {@link FileChooser} to display. | ||
| - * @return The file selected by the user. | ||
| - */ | ||
| - private Optional<File> saveOrExportAs( final FileChooser dialog ) { | ||
| - final var file = dialog.showSaveDialog( mParent ); | ||
| - | ||
| - setRecentDirectory( file ); | ||
| - | ||
| - return Optional.ofNullable( file ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Opens a new {@link FileChooser} at the previously selected directory. | ||
| - * If the initial directory is missing, this will attempt to default to | ||
| - * the user's home directory. If the home directory is missing, this will | ||
| - * use whatever JavaFX chooses for the initial directory. Without such an | ||
| - * intervention, an {@link IllegalArgumentException} would be thrown. | ||
| - * | ||
| - * @param key Message key from resource bundle. | ||
| - * @return {@link FileChooser} GUI allowing the user to pick a file. | ||
| - */ | ||
| - private FileChooser createFileChooser( final String key ) { | ||
| - final var prefDir = mDirectory.getValue(); | ||
| - final var openDir = prefDir.isDirectory() ? prefDir : USER_DIRECTORY; | ||
| - final var chooser = new FileChooser(); | ||
| - | ||
| - chooser.setTitle( get( key ) ); | ||
| - chooser.getExtensionFilters().addAll( createExtensionFilters() ); | ||
| - chooser.setInitialDirectory( openDir.isDirectory() ? openDir : null ); | ||
| - | ||
| - return chooser; | ||
| - } | ||
| - | ||
| - private List<ExtensionFilter> createExtensionFilters() { | ||
| - final List<ExtensionFilter> list = new ArrayList<>(); | ||
| - | ||
| - // TODO: Return a list of all properties that match the filter prefix. | ||
| - // This will allow dynamic filters to be added and removed just by | ||
| - // updating the properties file. | ||
| - list.add( createExtensionFilter( ALL ) ); | ||
| - list.add( createExtensionFilter( SOURCE ) ); | ||
| - list.add( createExtensionFilter( DEFINITION ) ); | ||
| - list.add( createExtensionFilter( XML ) ); | ||
| - | ||
| - return list; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns a filter for file name extensions recognized by the application | ||
| - * that can be opened by the user. | ||
| - * | ||
| - * @param filetype Used to find the globbing pattern for extensions. | ||
| - * @return A file name filter suitable for use by a FileDialog instance. | ||
| - */ | ||
| - private ExtensionFilter createExtensionFilter( | ||
| - final FileType filetype ) { | ||
| - final var tKey = format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype ); | ||
| - final var eKey = format( "%s.%s", GLOB_PREFIX_FILE, filetype ); | ||
| - | ||
| - return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Sets the value for the most recent directly selected. This will get the | ||
| - * parent location from the given file. If the parent is a readable directory | ||
| - * then this will update the most recent directory property. | ||
| - * | ||
| - * @param file A file contained in a directory. | ||
| - */ | ||
| - private void setRecentDirectory( final File file ) { | ||
| - if( file != null ) { | ||
| - final var parent = file.getParentFile(); | ||
| - final var dir = parent == null ? USER_DIRECTORY : parent; | ||
| - | ||
| - if( dir.isDirectory() && dir.canRead() ) { | ||
| - mDirectory.setValue( dir ); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - private List<String> getExtensions( final String key ) { | ||
| - return getSettings().getStringSettingList( key ); | ||
| - } | ||
| - | ||
| - private static Settings getSettings() { | ||
| - return sSettings; | ||
| - } | ||
| -} | ||
| "md", TEXT_MARKDOWN, | ||
| "Rmd", TEXT_R_MARKDOWN, | ||
| - "Rxml", TEXT_R_XML, | ||
| "txt", TEXT_PLAIN, | ||
| "yml", TEXT_YAML |
| Delta | 1703 lines added, 2482 lines removed, 779-line decrease |
|---|