Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git

Remove XML/XSL/SAXON, fix image scaling

AuthorDaveJarvis <email>
Date2021-05-24 20:03:02 GMT-0700
Commitc696142cd3fffee96943b5f59de008620e6e3b68
Parent3c45f71
README.md
* 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)
README.zh-CN.md
* 带变量替换的实时预览
* 基于变量值自动完成变量名
-* 使用XSLT3或更早版本的XML文档转换
* 独立于操作系统
* 打字时拼写检查
build.gradle
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
docs/credits.md
* 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)
docs/licenses/SAXON-HE.txt
- 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.
src/main/java/com/keenwrite/AbstractFileFactory.java
* 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.
src/main/java/com/keenwrite/MainPane.java
*/
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;
}
src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
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;
}
}
src/main/java/com/keenwrite/io/FileType.java
SOURCE( "source" ),
DEFINITION( "definition" ),
- XML( "xml" ),
CSV( "csv" ),
JSON( "json" ),
src/main/java/com/keenwrite/io/MediaType.java
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" ),
src/main/java/com/keenwrite/io/MediaTypeExtension.java
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 ),
src/main/java/com/keenwrite/preview/SmoothImageReplacedElement.java
}
+ /**
+ * Calculates scaled dimensions while maintaining the image aspect ratio.
+ */
private Dimension rescaleDimensions(
final BufferedImage bi, final int width, final int height ) {
src/main/java/com/keenwrite/processors/DefinitionProcessor.java
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;
src/main/java/com/keenwrite/processors/ProcessorContext.java
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;
src/main/java/com/keenwrite/processors/ProcessorFactory.java
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 );
}
src/main/java/com/keenwrite/processors/XmlProcessor.java
-/* 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 );
- }
-}
src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
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 ) );
src/main/java/com/keenwrite/processors/markdown/extensions/CaretExtension.java
@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 ) );
src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
@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() );
}
src/main/java/com/keenwrite/processors/r/RProcessor.java
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;
src/main/java/com/keenwrite/spelling/impl/TextEditorSpeller.java
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();
/**
src/main/java/com/keenwrite/ui/actions/FileChooserCommand.java
-/* 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;
- }
-}
src/test/java/com/keenwrite/io/MediaTypeTest.java
"md", TEXT_MARKDOWN,
"Rmd", TEXT_R_MARKDOWN,
- "Rxml", TEXT_R_XML,
"txt", TEXT_PLAIN,
"yml", TEXT_YAML
Delta1703 lines added, 2482 lines removed, 779-line decrease