| 1 | setwd( '$application.r.working.directory$' ) | |
| 2 | assign( "anchor", '$date.anchor$', envir = .GlobalEnv ) | |
| 1 | setwd( '{{application.r.working.directory}}' ) | |
| 2 | assign( "anchor", '{{date.anchor}}', envir = .GlobalEnv ) | |
| 3 | 3 | |
| 4 | 4 | source( 'pluralize.R' ) |
| 17 | 17 | ### Windows |
| 18 | 18 | |
| 19 | Double-click the application to start; give the application permission to run. | |
| 20 | ||
| 21 | 19 | When upgrading to a new version, delete the following directory: |
| 22 | 20 | |
| 23 | 21 | C:\Users\%USERNAME%\AppData\Local\warp\packages\keenwrite.exe |
| 22 | ||
| 23 | Double-click the application to start; give the application permission to run. | |
| 24 | 24 | |
| 25 | 25 | ### Linux |
| ... | ||
| 48 | 48 | * Real-time spell check |
| 49 | 49 | * Real-time rendering of math using TeX notation |
| 50 | * Diagrams: Mermaid, GraphViz, UML, sequence, timing, DITAA, and more! | |
| 50 | * Diagrams: Mermaid, GraphViz, UML, sequence, timing, and [many more](https://kroki.io/)! | |
| 51 | 51 | * R integration |
| 52 | 52 | * XML transformation using XSLT3 or older |
| 53 | * Customizable GUI having detachable tabs | |
| 54 | * Platform independent (Windows, Linux, MacOS) | |
| 53 | * Customizable user interface having detachable tabs | |
| 54 | * Platform-independent (Windows, Linux, MacOS) | |
| 55 | 55 | |
| 56 | 56 | ## Usage |
| 57 | 57 | |
| 58 | 58 | See the [detailed documentation](docs/README.md) for information about |
| 59 | 59 | using the application. |
| 60 | 60 | |
| 61 | ## Screenshot | |
| 61 | ## Screenshots | |
| 62 | 62 | |
| 63 |  | |
| 63 | Diagram that includes variables: | |
| 64 | ||
| 65 |  | |
| 66 | ||
| 67 | Poem with locale settings: | |
| 68 | ||
| 69 |  | |
| 70 | ||
| 71 | TeX equations with detached preview: | |
| 72 | ||
| 73 |  | |
| 64 | 74 | |
| 65 | 75 | ## License |
| 58 | 58 | ## 截图 |
| 59 | 59 | |
| 60 |  | |
| 60 |  | |
| 61 | ||
| 62 |  | |
| 63 | ||
| 64 |  | |
| 65 | ||
| 61 | 66 | |
| 62 | 67 | ## 软件许可证 |
| 1 | # Introduction | |
| 2 | ||
| 3 | From a high level, the application architecture for converting Markdown documents is captured in the following figure: | |
| 4 | ||
| 5 | ``` diagram-graphviz | |
| 6 | digraph { | |
| 7 | node [fontname = "Noto Sans" fontsize=6 height=.25 penwidth=.5]; | |
| 8 | edge [fontname = "Noto Sans" fontsize=6 penwidth=.5 arrowsize=.5]; | |
| 9 | node [shape=box color="{{keenwrite.palette.primary.light}}" fontcolor="{{keenwrite.palette.primary.dark}}"] | |
| 10 | edge [color="{{keenwrite.palette.grayscale.light}}" fontcolor="{{keenwrite.palette.grayscale.dark}}"] | |
| 11 | ||
| 12 | {{keenwrite.classes.processors.variable.definition}} -> {{keenwrite.classes.processors.markdown}} [xlabel="{{keenwrite.graph.label.chain.next}} "] | |
| 13 | {{keenwrite.classes.processors.markdown}} -> {{keenwrite.classes.processors.preview}} [xlabel="{{keenwrite.graph.label.chain.next}} "] | |
| 14 | {{keenwrite.classes.processors.markdown}} -> Extensions [label=" contains"] | |
| 15 | ||
| 16 | Extensions -> FencedBlockExtension | |
| 17 | Extensions -> CaretExtension | |
| 18 | Extensions -> ImageLinkExtension | |
| 19 | Extensions -> TeXExtension | |
| 20 | } | |
| 21 | ``` | |
| 22 | ||
| 23 | An extension is an addition to the Markdown parser, flexmark-java, that is used when converting the document's abstract syntax tree into an HTML document. The {{keenwrite.classes.processors.markdown}} contains both prepackaged and custom extensions. | |
| 1 | 24 |
| 1 | --- | |
| 2 | keenwrite: | |
| 3 | classes: | |
| 4 | processors: | |
| 5 | markdown: "MarkdownProcessor" | |
| 6 | variable: | |
| 7 | definition: "DefinitionProcessor" | |
| 8 | preview: "PreviewProcessor" | |
| 9 | palette: | |
| 10 | primary: | |
| 11 | light: "#51a9cf" | |
| 12 | dark: "#126d95" | |
| 13 | secondary: | |
| 14 | light: "#ec706a" | |
| 15 | dark: "#7e252f" | |
| 16 | accent: | |
| 17 | light: "#76A786" | |
| 18 | dark: "#385742" | |
| 19 | grayscale: | |
| 20 | light: "#bac2c5" | |
| 21 | dark: "#394343" | |
| 22 | graph: | |
| 23 | label: | |
| 24 | chain: | |
| 25 | next: "successor" | |
| 1 | 26 |
| 1 | <svg height="197.4767" viewBox="0 0 695.99768 197.4767" width="695.99768" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(-8.7796153 42.985832 -42.985832 -8.7796153 810.33577 828.59028)" gradientUnits="userSpaceOnUse" x1=".152358" x2=".968809" y1="-.044912" y2="-.049471"><stop offset="0" stop-color="#ec706a"/><stop offset="1" stop-color="#ecd980"/></linearGradient><g transform="translate(-295.50101 -692.52836)"><path d="m793.12811 845.45734c-1.09438 20.55837 6.93804 24.54772 6.93804 24.54772s.98325-13.16026 6.76656-11.6325c4.96369 1.30552 2.67983 10.4134 2.67983 10.4134s26.21535-34.03672 1.372-40.63137-5.51534-1.89773-10.40994.92679c-3.58074 2.06734-6.82887 6.66097-7.34649 16.37596" fill="url(#a)"/><path d="m826.30436 831.16428-10.99206-16.95952 1.75995-6.49966 10.01483 2.71233z" fill="#126d95"/><path d="m828.56081 804.89512-.91739 3.38458-9.99361-2.70665.91739-3.38458z" fill="#126d95"/><g fill="#51a9cf"><path d="m834.01973 741.0381c-1.68105.0185-3.22054 1.13771-3.68367 2.84981-.56186 2.07405.665 4.21099 2.73743 4.77241l-13.96475 51.52944-9.99361-2.70665c8.36013-31.46487 4.99411-51.98144 4.99411-51.98144 14.99782-11.92097 23.67-25.56577 27.63101-32.97331z"/><path d="m818.56767 802.18881-.9174 3.38458-10.03996-2.72957.91314-3.37522z"/><path d="m817.07405 807.70594-1.75995 6.49966-18.03534 9.08805 9.78412-18.31044z"/></g><path d="m836.1981 741.64919 7.72577-28.52932c-.3195 8.40427.28451 24.55036 7.21678 42.41047 0 0-11.89603 16.50235-21.99788 47.3763l-10.03442-2.71758 13.96533-51.5284c2.08221.56405 4.21039-.66603 4.77182-2.73844.45427-1.67248-.26571-3.38317-1.64739-4.27302" fill="#126d95"/></g><text transform="translate(-295.73751 -689.6407)"/><g style="font-style:italic;font-weight:800;font-size:133.333;font-family:Merriweather Sans;letter-spacing:0;word-spacing:0;fill:#51a9cf"><text x="16.133343" y="130.6234"><tspan x="16.133343" y="130.6234">KeenWr</tspan></text><text x="552.53137" y="130.6234"><tspan x="552.53137" y="130.6234">te</tspan></text></g></svg> | |
| 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | |
| 2 | <svg | |
| 3 | xmlns:dc="http://purl.org/dc/elements/1.1/" | |
| 4 | xmlns:cc="http://creativecommons.org/ns#" | |
| 5 | xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | |
| 6 | xmlns:svg="http://www.w3.org/2000/svg" | |
| 7 | xmlns="http://www.w3.org/2000/svg" | |
| 8 | xmlns:xlink="http://www.w3.org/1999/xlink" | |
| 9 | xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | |
| 10 | xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | |
| 11 | height="197.4767" | |
| 12 | viewBox="0 0 695.99768 197.4767" | |
| 13 | width="695.99768" | |
| 14 | version="1.1" | |
| 15 | id="svg37" | |
| 16 | sodipodi:docname="new-logo-text.svg" | |
| 17 | inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"> | |
| 18 | <metadata | |
| 19 | id="metadata43"> | |
| 20 | <rdf:RDF> | |
| 21 | <cc:Work | |
| 22 | rdf:about=""> | |
| 23 | <dc:format>image/svg+xml</dc:format> | |
| 24 | <dc:type | |
| 25 | rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> | |
| 26 | <dc:title></dc:title> | |
| 27 | </cc:Work> | |
| 28 | </rdf:RDF> | |
| 29 | </metadata> | |
| 30 | <defs | |
| 31 | id="defs41"> | |
| 32 | <linearGradient | |
| 33 | id="a" | |
| 34 | gradientTransform="matrix(-8.7796153,42.985832,-42.985832,-8.7796153,514.83476,136.06192)" | |
| 35 | gradientUnits="userSpaceOnUse" | |
| 36 | x1=".152358" | |
| 37 | x2=".968809" | |
| 38 | y1="-.044912" | |
| 39 | y2="-.049471"> | |
| 40 | <stop | |
| 41 | offset="0" | |
| 42 | stop-color="#ec706a" | |
| 43 | id="stop2" /> | |
| 44 | <stop | |
| 45 | offset="1" | |
| 46 | stop-color="#ecd980" | |
| 47 | id="stop4" /> | |
| 48 | </linearGradient> | |
| 49 | </defs> | |
| 50 | <path | |
| 51 | style="fill:url(#a);fill-opacity:1.0;fill-rule:nonzero;stroke:none;stroke-width:1.226;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0" | |
| 52 | paint-order="stroke" | |
| 53 | d="m 496.76229,150.80474 c -4.25368,20.68081 3.28191,25.95476 3.28191,25.95476 v 0 c 0,0 3.00963,-13.19543 8.64082,-10.76172 v 0 c 4.83401,2.08299 1.12516,10.97002 1.12516,10.97002 v 0 c 0,0 31.78993,-30.5076 7.60484,-40.99434 v 0 c 0,0 -5.30287,-2.76791 -10.69842,-0.65209 v 0 c -3.94735,1.54891 -7.94375,5.71058 -9.95431,15.48337" | |
| 54 | stroke-linecap="round" | |
| 55 | id="path14" /> | |
| 56 | <path | |
| 57 | d="m 530.80335,138.63592 -10.99206,-16.95952 1.75995,-6.49966 10.01483,2.71233 z" | |
| 58 | fill="#126d95" | |
| 59 | id="path9" /> | |
| 60 | <path | |
| 61 | d="m 533.0598,112.36676 -0.91739,3.38458 -9.99361,-2.70665 0.91739,-3.38458 z" | |
| 62 | fill="#126d95" | |
| 63 | id="path11" /> | |
| 64 | <g | |
| 65 | fill="#51a9cf" | |
| 66 | id="g19" | |
| 67 | transform="translate(-295.50101,-692.52836)"> | |
| 68 | <path | |
| 69 | d="m 834.01973,741.0381 c -1.68105,0.0185 -3.22054,1.13771 -3.68367,2.84981 -0.56186,2.07405 0.665,4.21099 2.73743,4.77241 l -13.96475,51.52944 -9.99361,-2.70665 c 8.36013,-31.46487 4.99411,-51.98144 4.99411,-51.98144 14.99782,-11.92097 23.67,-25.56577 27.63101,-32.97331 z" | |
| 70 | id="path13" /> | |
| 71 | <path | |
| 72 | d="m 818.56767,802.18881 -0.9174,3.38458 -10.03996,-2.72957 0.91314,-3.37522 z" | |
| 73 | id="path15" /> | |
| 74 | <path | |
| 75 | d="m 817.07405,807.70594 -1.75995,6.49966 -18.03534,9.08805 9.78412,-18.31044 z" | |
| 76 | id="path17" /> | |
| 77 | </g> | |
| 78 | <path | |
| 79 | d="m 540.69709,49.12083 7.72577,-28.52932 c -0.3195,8.40427 0.28451,24.55036 7.21678,42.41047 0,0 -11.89603,16.50235 -21.99788,47.3763 l -10.03442,-2.71758 13.96533,-51.5284 c 2.08221,0.56405 4.21039,-0.66603 4.77182,-2.73844 0.45427,-1.67248 -0.26571,-3.38317 -1.64739,-4.27302" | |
| 80 | fill="#126d95" | |
| 81 | id="path21" /> | |
| 82 | <text | |
| 83 | transform="translate(-295.73751 -689.6407)" | |
| 84 | id="text25" /> | |
| 85 | <g | |
| 86 | style="font-style:italic;font-weight:800;font-size:133.333;font-family:Merriweather Sans;letter-spacing:0;word-spacing:0;fill:#51a9cf" | |
| 87 | id="g35"> | |
| 88 | <text | |
| 89 | x="16.133343" | |
| 90 | y="130.6234" | |
| 91 | id="text29"><tspan | |
| 92 | x="16.133343" | |
| 93 | y="130.6234" | |
| 94 | id="tspan27">KeenWr</tspan></text> | |
| 95 | <text | |
| 96 | x="552.53137" | |
| 97 | y="130.6234" | |
| 98 | id="text33"><tspan | |
| 99 | x="552.53137" | |
| 100 | y="130.6234" | |
| 101 | id="tspan31">te</tspan></text> | |
| 102 | </g> | |
| 103 | </svg> | |
| 104 |
| 1 | 1 | |
| 2 | Blues | |
| 3 | Light - 51a9cf | |
| 4 | Dark - 126d95 | |
| 5 | ||
| 6 | Red & Yellow | |
| 7 | Light yellow - ecd980 | |
| 8 | Light red - ec706a | |
| 9 | Dark red - 7e252f | |
| 10 | ||
| 11 | Greens | |
| 12 | Light - 76A786 | |
| 13 | Dark - 385742 | |
| 14 | ||
| 15 | Grayscale | |
| 16 | Light - bac2c5 | |
| 17 | Dark - 394343 | |
| 18 | ||
| 19 |
| 1 | --- | |
| 2 | formula: | |
| 3 | sqrt: | |
| 4 | value: "420" | |
| 5 | quadratic: | |
| 6 | a: "25" | |
| 7 | b: "84.906" | |
| 8 | c: "20" | |
| 1 | 9 |
| 1 | --- | |
| 2 | formula: | |
| 3 | sqrt: | |
| 4 | value: "420" | |
| 5 | quadratic: | |
| 6 | a: "25" | |
| 7 | b: "84.906" | |
| 8 | c: "20" | |
| 9 | 1 |
| 1 | # Fonts | |
| 2 | ||
| 3 | For best results, it is recommended that the Noto Font family is installed | |
| 4 | on the system. The required font families include: | |
| 5 | ||
| 6 | * Sans-serif --- editor pane | |
| 7 | * Serif --- preview pane | |
| 8 | * Serif monospace --- preview pane | |
| 9 | ||
| 10 | # Chinese, Japanese, and Korean (CJK) | |
| 11 | ||
| 12 | Download and install from the following font bundles: | |
| 13 | ||
| 14 | * [Hong Kong](noto-hk.zip) | |
| 15 | * [Japanese](noto-jp.zip) | |
| 16 | * [Korean](noto-kr.zip) | |
| 17 | * [Simplified Chinese](noto-sc.zip) | |
| 18 | * [Traditional Chinese](noto-tc.zip) | |
| 19 | ||
| 20 | Except for Hong Kong, each bundle contains all the required font families; | |
| 21 | Hong Kong must be paired with the Simplified Chinese. | |
| 22 | ||
| 23 | The [official versions](https://www.google.com/get/noto/) of these fonts | |
| 24 | are updated regularly at the Noto Fonts | |
| 25 | [repository](https://github.com/googlefonts/noto-fonts/). If downloading | |
| 26 | from the original location, be sure to retrieve all font families needed | |
| 27 | for the application to render text correctly. | |
| 28 | ||
| 29 | # Internationalization | |
| 30 | ||
| 31 | Fonts for other languages may work but have not been tested. | |
| 32 | ||
| 1 | 33 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.util.GenericBuilder; | |
| 5 | import javafx.beans.value.ObservableValue; | |
| 6 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 7 | import org.fxmisc.richtext.model.Paragraph; | |
| 8 | import org.reactfx.collection.LiveList; | |
| 9 | ||
| 10 | import java.util.Collection; | |
| 11 | ||
| 12 | import static com.keenwrite.Constants.STATUS_BAR_LINE; | |
| 13 | import static com.keenwrite.Messages.get; | |
| 14 | ||
| 15 | /** | |
| 16 | * Represents the absolute, relative, and maximum position of the caret. The | |
| 17 | * caret position is a character offset into the text. | |
| 18 | */ | |
| 19 | public class Caret { | |
| 20 | ||
| 21 | public static GenericBuilder<Caret.Mutator, Caret> builder() { | |
| 22 | return GenericBuilder.of( Caret.Mutator::new, Caret::new ); | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * Used for building a new {@link Caret} instance. | |
| 27 | */ | |
| 28 | public static class Mutator { | |
| 29 | /** | |
| 30 | * Caret's current paragraph index (i.e., current caret line number). | |
| 31 | */ | |
| 32 | private ObservableValue<Integer> mParagraph; | |
| 33 | ||
| 34 | /** | |
| 35 | * Used to count the number of lines in the text editor document. | |
| 36 | */ | |
| 37 | private LiveList<Paragraph<Collection<String>, String, | |
| 38 | Collection<String>>> mParagraphs; | |
| 39 | ||
| 40 | /** | |
| 41 | * Caret offset into the full text, represented as a string index. | |
| 42 | */ | |
| 43 | private ObservableValue<Integer> mTextOffset; | |
| 44 | ||
| 45 | /** | |
| 46 | * Caret offset into the current paragraph, represented as a string index. | |
| 47 | */ | |
| 48 | private ObservableValue<Integer> mParaOffset; | |
| 49 | ||
| 50 | /** | |
| 51 | * Total number of characters in the document. | |
| 52 | */ | |
| 53 | private ObservableValue<Integer> mTextLength; | |
| 54 | ||
| 55 | /** | |
| 56 | * Configures this caret position using properties from the given editor. | |
| 57 | * | |
| 58 | * @param editor The text editor that has a caret with position properties. | |
| 59 | */ | |
| 60 | public void setEditor( final StyleClassedTextArea editor ) { | |
| 61 | mParagraph = editor.currentParagraphProperty(); | |
| 62 | mParagraphs = editor.getParagraphs(); | |
| 63 | mParaOffset = editor.caretColumnProperty(); | |
| 64 | mTextOffset = editor.caretPositionProperty(); | |
| 65 | mTextLength = editor.lengthProperty(); | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | private final Mutator mMutator; | |
| 70 | ||
| 71 | /** | |
| 72 | * Force using the builder pattern. | |
| 73 | */ | |
| 74 | private Caret( final Mutator mutator ) { | |
| 75 | assert mutator != null; | |
| 76 | ||
| 77 | mMutator = mutator; | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Allows observers to be notified when the value of the caret changes. | |
| 82 | * | |
| 83 | * @return An observer for the caret's document offset. | |
| 84 | */ | |
| 85 | public ObservableValue<Integer> textOffsetProperty() { | |
| 86 | return mMutator.mTextOffset; | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Answers whether the caret's offset into the text is between the given | |
| 91 | * offsets. | |
| 92 | * | |
| 93 | * @param began Starting value compared against the caret's text offset. | |
| 94 | * @param ended Ending value compared against the caret's text offset. | |
| 95 | * @return {@code true} when the caret's text offset is between the given | |
| 96 | * values, inclusively (for either value). | |
| 97 | */ | |
| 98 | public boolean isBetweenText( final int began, final int ended ) { | |
| 99 | final var offset = getTextOffset(); | |
| 100 | return began <= offset && offset <= ended; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Answers whether the caret's offset into the paragraph is before the given | |
| 105 | * offset. | |
| 106 | * | |
| 107 | * @param offset Compared against the caret's paragraph offset. | |
| 108 | * @return {@code true} the caret's offset is before the given offset. | |
| 109 | */ | |
| 110 | public boolean isBeforeColumn( final int offset ) { | |
| 111 | return getParaOffset() < offset; | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Answers whether the caret's offset into the text is before the given | |
| 116 | * text offset. | |
| 117 | * | |
| 118 | * @param offset Compared against the caret's text offset. | |
| 119 | * @return {@code true} the caret's offset is after the given offset. | |
| 120 | */ | |
| 121 | public boolean isAfterColumn( final int offset ) { | |
| 122 | return getParaOffset() > offset; | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Answers whether the caret's offset into the text exceeds the length of | |
| 127 | * the text. | |
| 128 | * | |
| 129 | * @return {@code true} when the caret is at the end of the text boundary. | |
| 130 | */ | |
| 131 | public boolean isAfterText() { | |
| 132 | return getTextOffset() >= getTextLength(); | |
| 133 | } | |
| 134 | ||
| 135 | public boolean isAfter( final int offset ) { | |
| 136 | return offset >= getTextOffset(); | |
| 137 | } | |
| 138 | ||
| 139 | private int getParagraph() { | |
| 140 | return mMutator.mParagraph.getValue(); | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Returns the number of lines in the text editor. | |
| 145 | * | |
| 146 | * @return The size of the text editor's paragraph list plus one. | |
| 147 | */ | |
| 148 | private int getParagraphCount() { | |
| 149 | return mMutator.mParagraphs.size() + 1; | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Returns the absolute position of the caret within the entire document. | |
| 154 | * | |
| 155 | * @return A zero-based index of the caret position. | |
| 156 | */ | |
| 157 | private int getTextOffset() { | |
| 158 | return mMutator.mTextOffset.getValue(); | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Returns the position of the caret within the current paragraph being | |
| 163 | * edited. | |
| 164 | * | |
| 165 | * @return A zero-based index of the caret position relative to the | |
| 166 | * current paragraph. | |
| 167 | */ | |
| 168 | private int getParaOffset() { | |
| 169 | return mMutator.mParaOffset.getValue(); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Returns the total number of characters in the document being edited. | |
| 174 | * | |
| 175 | * @return A zero-based count of the total characters in the document. | |
| 176 | */ | |
| 177 | private int getTextLength() { | |
| 178 | return mMutator.mTextLength.getValue(); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Returns a human-readable string that shows the current caret position | |
| 183 | * within the text. Typically this will include the current line number, | |
| 184 | * the number of lines, and the character offset into the text. | |
| 185 | * | |
| 186 | * @return A string to present to an end user. | |
| 187 | */ | |
| 188 | @Override | |
| 189 | public String toString() { | |
| 190 | return get( STATUS_BAR_LINE, | |
| 191 | getParagraph() + 1, | |
| 192 | getParagraphCount(), | |
| 193 | getTextOffset() + 1 ); | |
| 194 | } | |
| 195 | } | |
| 1 | 196 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.service.Settings; |
| 5 | 5 | import javafx.scene.image.Image; |
| 6 | import javafx.scene.image.ImageView; | |
| 6 | 7 | |
| 7 | 8 | import java.io.File; |
| ... | ||
| 74 | 75 | |
| 75 | 76 | public static final Image ICON_DIALOG = LOGOS.get( 1 ); |
| 77 | public static final ImageView ICON_DIALOG_NODE = new ImageView( ICON_DIALOG ); | |
| 76 | 78 | |
| 77 | 79 | public static final String FILE_PREFERENCES = getPreferencesFilename(); |
| ... | ||
| 121 | 123 | public static final File USER_DIRECTORY = |
| 122 | 124 | new File( System.getProperty( "user.dir" ) ); |
| 125 | ||
| 126 | public static final String NEWLINE = System.lineSeparator(); | |
| 123 | 127 | |
| 124 | 128 | /** |
| ... | ||
| 163 | 167 | */ |
| 164 | 168 | public static final String FONT_DIRECTORY = "/fonts"; |
| 169 | ||
| 170 | /** | |
| 171 | * Default text editor font name. | |
| 172 | */ | |
| 173 | public static final String FONT_NAME_EDITOR_DEFAULT = "Noto Sans Regular"; | |
| 165 | 174 | |
| 166 | 175 | /** |
| 167 | 176 | * Default text editor font size, in points. |
| 168 | 177 | */ |
| 169 | 178 | public static final float FONT_SIZE_EDITOR_DEFAULT = 12f; |
| 179 | ||
| 180 | /** | |
| 181 | * Default preview font name. | |
| 182 | */ | |
| 183 | public static final String FONT_NAME_PREVIEW_DEFAULT = "Source Serif Pro"; | |
| 170 | 184 | |
| 171 | 185 | /** |
| 172 | 186 | * Default preview font size, in points. |
| 173 | 187 | */ |
| 174 | 188 | public static final float FONT_SIZE_PREVIEW_DEFAULT = 13f; |
| 189 | ||
| 190 | /** | |
| 191 | * Default monospace preview font name. | |
| 192 | */ | |
| 193 | public static final String FONT_NAME_PREVIEW_MONO_NAME_DEFAULT = "Source Code Pro"; | |
| 194 | ||
| 195 | /** | |
| 196 | * Default monospace preview font size, in points. | |
| 197 | */ | |
| 198 | public static final float FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT = 13f; | |
| 175 | 199 | |
| 176 | 200 | /** |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.editors.TextDefinition; |
| 5 | 5 | import com.keenwrite.editors.TextEditor; |
| 6 | 6 | import com.keenwrite.editors.definition.DefinitionTreeItem; |
| 7 | 7 | import com.keenwrite.sigils.SigilOperator; |
| 8 | 8 | |
| 9 | 9 | import static com.keenwrite.Constants.*; |
| 10 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 10 | import static com.keenwrite.StatusNotifier.clue; | |
| 11 | 11 | |
| 12 | 12 | /** |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.editors.TextDefinition; | |
| 5 | import com.keenwrite.editors.TextEditor; | |
| 6 | import com.keenwrite.editors.TextResource; | |
| 7 | import com.keenwrite.editors.definition.DefinitionEditor; | |
| 8 | import com.keenwrite.editors.definition.DefinitionTabSceneFactory; | |
| 9 | import com.keenwrite.editors.definition.TreeTransformer; | |
| 10 | import com.keenwrite.editors.definition.yaml.YamlTreeTransformer; | |
| 11 | import com.keenwrite.editors.markdown.MarkdownEditor; | |
| 12 | import com.keenwrite.io.MediaType; | |
| 13 | import com.keenwrite.preferences.Key; | |
| 14 | import com.keenwrite.preferences.Workspace; | |
| 15 | import com.keenwrite.preview.HtmlPreview; | |
| 16 | import com.keenwrite.processors.IdentityProcessor; | |
| 17 | import com.keenwrite.processors.Processor; | |
| 18 | import com.keenwrite.processors.ProcessorContext; | |
| 19 | import com.keenwrite.processors.ProcessorFactory; | |
| 20 | import com.keenwrite.processors.markdown.Caret; | |
| 21 | import com.keenwrite.processors.markdown.CaretExtension; | |
| 22 | import com.keenwrite.service.events.Notifier; | |
| 23 | import com.keenwrite.sigils.RSigilOperator; | |
| 24 | import com.keenwrite.sigils.SigilOperator; | |
| 25 | import com.keenwrite.sigils.Tokens; | |
| 26 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 27 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 28 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 29 | import javafx.beans.property.*; | |
| 30 | import javafx.collections.ListChangeListener; | |
| 31 | import javafx.event.ActionEvent; | |
| 32 | import javafx.event.Event; | |
| 33 | import javafx.event.EventHandler; | |
| 34 | import javafx.scene.Scene; | |
| 35 | import javafx.scene.control.SplitPane; | |
| 36 | import javafx.scene.control.Tab; | |
| 37 | import javafx.scene.control.Tooltip; | |
| 38 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 39 | import javafx.scene.input.KeyEvent; | |
| 40 | import javafx.stage.Stage; | |
| 41 | import javafx.stage.Window; | |
| 42 | ||
| 43 | import java.io.File; | |
| 44 | import java.nio.file.Path; | |
| 45 | import java.util.*; | |
| 46 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 47 | import java.util.function.Function; | |
| 48 | import java.util.stream.Collectors; | |
| 49 | ||
| 50 | import static com.keenwrite.Constants.*; | |
| 51 | import static com.keenwrite.ExportFormat.NONE; | |
| 52 | import static com.keenwrite.Messages.get; | |
| 53 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 54 | import static com.keenwrite.io.MediaType.*; | |
| 55 | import static com.keenwrite.preferences.Workspace.*; | |
| 56 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 57 | import static com.keenwrite.service.events.Notifier.NO; | |
| 58 | import static com.keenwrite.service.events.Notifier.YES; | |
| 59 | import static java.util.stream.Collectors.groupingBy; | |
| 60 | import static javafx.application.Platform.runLater; | |
| 61 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 62 | import static javafx.scene.input.KeyCode.SPACE; | |
| 63 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 64 | import static javafx.util.Duration.millis; | |
| 65 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 66 | ||
| 67 | /** | |
| 68 | * Responsible for wiring together the main application components for a | |
| 69 | * particular workspace (project). These include the definition views, | |
| 70 | * text editors, and preview pane along with any corresponding controllers. | |
| 71 | */ | |
| 72 | public final class MainPane extends SplitPane { | |
| 73 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 74 | ||
| 75 | /** | |
| 76 | * Used when opening files to determine how each file should be binned and | |
| 77 | * therefore what tab pane to be opened within. | |
| 78 | */ | |
| 79 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 80 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED | |
| 81 | ); | |
| 82 | ||
| 83 | /** | |
| 84 | * Prevents re-instantiation of processing classes. | |
| 85 | */ | |
| 86 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 87 | new HashMap<>(); | |
| 88 | ||
| 89 | private final Workspace mWorkspace; | |
| 90 | ||
| 91 | /** | |
| 92 | * Groups similar file type tabs together. | |
| 93 | */ | |
| 94 | private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>(); | |
| 95 | ||
| 96 | /** | |
| 97 | * Stores definition names and values. | |
| 98 | */ | |
| 99 | private final Map<String, String> mResolvedMap = | |
| 100 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 101 | ||
| 102 | /** | |
| 103 | * Renders the actively selected plain text editor tab. | |
| 104 | */ | |
| 105 | private final HtmlPreview mHtmlPreview; | |
| 106 | ||
| 107 | /** | |
| 108 | * Changing the active editor fires the value changed event. This allows | |
| 109 | * refreshes to happen when external definitions are modified and need to | |
| 110 | * trigger the processing chain. | |
| 111 | */ | |
| 112 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 113 | createActiveTextEditor(); | |
| 114 | ||
| 115 | /** | |
| 116 | * Changing the active definition editor fires the value changed event. This | |
| 117 | * allows refreshes to happen when external definitions are modified and need | |
| 118 | * to trigger the processing chain. | |
| 119 | */ | |
| 120 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 121 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 122 | ||
| 123 | /** | |
| 124 | * Responsible for creating a new scene when a tab is detached into | |
| 125 | * its own window frame. | |
| 126 | */ | |
| 127 | private final DefinitionTabSceneFactory mDefinitionTabSceneFactory = | |
| 128 | createDefinitionTabSceneFactory( mActiveDefinitionEditor ); | |
| 129 | ||
| 130 | /** | |
| 131 | * Tracks the number of detached tab panels opened into their own windows, | |
| 132 | * which allows unique identification of subordinate windows by their title. | |
| 133 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 134 | */ | |
| 135 | private byte mWindowCount; | |
| 136 | ||
| 137 | /** | |
| 138 | * Called when the definition data is changed. | |
| 139 | */ | |
| 140 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 141 | event -> { | |
| 142 | final var editor = mActiveDefinitionEditor.get(); | |
| 143 | ||
| 144 | resolve( editor ); | |
| 145 | process( getActiveTextEditor() ); | |
| 146 | save( editor ); | |
| 147 | }; | |
| 148 | ||
| 149 | /** | |
| 150 | * Adds all content panels to the main user interface. This will load the | |
| 151 | * configuration settings from the workspace to reproduce the settings from | |
| 152 | * a previous session. | |
| 153 | */ | |
| 154 | public MainPane( final Workspace workspace ) { | |
| 155 | mWorkspace = workspace; | |
| 156 | mHtmlPreview = new HtmlPreview( workspace ); | |
| 157 | ||
| 158 | open( bin( getRecentFiles() ) ); | |
| 159 | viewPreview(); | |
| 160 | ||
| 161 | final var ratio = 100f / getItems().size() / 100; | |
| 162 | final var positions = getDividerPositions(); | |
| 163 | ||
| 164 | for( int i = 0; i < positions.length; i++ ) { | |
| 165 | positions[ i ] = ratio * i; | |
| 166 | } | |
| 167 | ||
| 168 | // TODO: Load divider positions from exported settings, see bin() comment. | |
| 169 | setDividerPositions( positions ); | |
| 170 | ||
| 171 | // Once the main scene's window regains focus, update the active definition | |
| 172 | // editor to the currently selected tab. | |
| 173 | runLater( | |
| 174 | () -> getWindow().focusedProperty().addListener( ( c, o, n ) -> { | |
| 175 | if( n != null && n ) { | |
| 176 | final var pane = mTabPanes.get( TEXT_YAML ); | |
| 177 | final var model = pane.getSelectionModel(); | |
| 178 | final var tab = model.getSelectedItem(); | |
| 179 | ||
| 180 | if( tab != null ) { | |
| 181 | final var editor = (TextDefinition) tab.getContent(); | |
| 182 | ||
| 183 | mActiveDefinitionEditor.set( editor ); | |
| 184 | } | |
| 185 | } | |
| 186 | } ) | |
| 187 | ); | |
| 188 | } | |
| 189 | ||
| 190 | /** | |
| 191 | * Opens all the files into the application, provided the paths are unique. | |
| 192 | * This may only be called for any type of files that a user can edit | |
| 193 | * (i.e., update and persist), such as definitions and text files. | |
| 194 | * | |
| 195 | * @param files The list of files to open. | |
| 196 | */ | |
| 197 | public void open( final List<File> files ) { | |
| 198 | files.forEach( this::open ); | |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * This opens the given file. Since the preview pane is not a file that | |
| 203 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 204 | * | |
| 205 | * @param file The file to open. | |
| 206 | */ | |
| 207 | private void open( final File file ) { | |
| 208 | final var tab = createTab( file ); | |
| 209 | final var node = tab.getContent(); | |
| 210 | final var mediaType = MediaType.valueFrom( file ); | |
| 211 | final var tabPane = obtainDetachableTabPane( mediaType ); | |
| 212 | final var newTabPane = !getItems().contains( tabPane ); | |
| 213 | ||
| 214 | tab.setTooltip( createTooltip( file ) ); | |
| 215 | tabPane.setFocusTraversable( false ); | |
| 216 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 217 | tabPane.getTabs().add( tab ); | |
| 218 | ||
| 219 | if( newTabPane ) { | |
| 220 | var index = getItems().size(); | |
| 221 | ||
| 222 | if( node instanceof TextDefinition ) { | |
| 223 | tabPane.setSceneFactory( mDefinitionTabSceneFactory::create ); | |
| 224 | index = 0; | |
| 225 | } | |
| 226 | ||
| 227 | addTabPane( index, tabPane ); | |
| 228 | } | |
| 229 | ||
| 230 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 231 | } | |
| 232 | ||
| 233 | /** | |
| 234 | * Opens a new text editor document using the default document file name. | |
| 235 | */ | |
| 236 | public void newTextEditor() { | |
| 237 | open( DOCUMENT_DEFAULT ); | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Opens a new definition editor document using the default definition | |
| 242 | * file name. | |
| 243 | */ | |
| 244 | public void newDefinitionEditor() { | |
| 245 | open( DEFINITION_DEFAULT ); | |
| 246 | } | |
| 247 | ||
| 248 | /** | |
| 249 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 250 | * that they save themselves. | |
| 251 | */ | |
| 252 | public void saveAll() { | |
| 253 | mTabPanes.forEach( | |
| 254 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 255 | final var node = tab.getContent(); | |
| 256 | if( node instanceof TextEditor ) { | |
| 257 | save( ((TextEditor) node) ); | |
| 258 | } | |
| 259 | } ) | |
| 260 | ); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 265 | * checking if modified first because if the user swaps external media from | |
| 266 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 267 | * the user: save always re-saves. Also, it's less code. | |
| 268 | */ | |
| 269 | public void save() { | |
| 270 | save( getActiveTextEditor() ); | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * Saves the active {@link TextEditor} under a new name. | |
| 275 | * | |
| 276 | * @param file The new active editor {@link File} reference. | |
| 277 | */ | |
| 278 | public void saveAs( final File file ) { | |
| 279 | assert file != null; | |
| 280 | final var editor = getActiveTextEditor(); | |
| 281 | final var tab = getTab( editor ); | |
| 282 | ||
| 283 | editor.rename( file ); | |
| 284 | tab.ifPresent( t -> { | |
| 285 | t.setText( editor.getFilename() ); | |
| 286 | t.setTooltip( createTooltip( file ) ); | |
| 287 | } ); | |
| 288 | ||
| 289 | save(); | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 294 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 295 | * | |
| 296 | * @param resource The resource to export. | |
| 297 | */ | |
| 298 | private void save( final TextResource resource ) { | |
| 299 | try { | |
| 300 | resource.save(); | |
| 301 | } catch( final Exception ex ) { | |
| 302 | clue( ex ); | |
| 303 | sNotifier.alert( | |
| 304 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 305 | ); | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 311 | * | |
| 312 | * @return {@code true} when all editors, modified or otherwise, were | |
| 313 | * permitted to close; {@code false} when one or more editors were modified | |
| 314 | * and the user requested no closing. | |
| 315 | */ | |
| 316 | public boolean closeAll() { | |
| 317 | var closable = true; | |
| 318 | ||
| 319 | for( final var entry : mTabPanes.entrySet() ) { | |
| 320 | final var tabPane = entry.getValue(); | |
| 321 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 322 | ||
| 323 | while( tabIterator.hasNext() ) { | |
| 324 | final var tab = tabIterator.next(); | |
| 325 | final var node = tab.getContent(); | |
| 326 | ||
| 327 | if( node instanceof TextEditor && | |
| 328 | (closable &= canClose( (TextEditor) node )) ) { | |
| 329 | tabIterator.remove(); | |
| 330 | close( tab ); | |
| 331 | } | |
| 332 | } | |
| 333 | } | |
| 334 | ||
| 335 | return closable; | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 340 | * event. | |
| 341 | * | |
| 342 | * @param tab The {@link Tab} that was closed. | |
| 343 | */ | |
| 344 | private void close( final Tab tab ) { | |
| 345 | final var handler = tab.getOnClosed(); | |
| 346 | ||
| 347 | if( handler != null ) { | |
| 348 | handler.handle( new ActionEvent() ); | |
| 349 | } | |
| 350 | } | |
| 351 | ||
| 352 | /** | |
| 353 | * Closes the active tab; delegates to {@link #canClose(TextEditor)}. | |
| 354 | */ | |
| 355 | public void close() { | |
| 356 | final var editor = getActiveTextEditor(); | |
| 357 | if( canClose( editor ) ) { | |
| 358 | close( editor ); | |
| 359 | } | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * Closes the given {@link TextEditor}. This must not be called from within | |
| 364 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 365 | * concurrent modification exception be thrown. | |
| 366 | * | |
| 367 | * @param editor The {@link TextEditor} to close, without confirming with | |
| 368 | * the user. | |
| 369 | */ | |
| 370 | private void close( final TextEditor editor ) { | |
| 371 | getTab( editor ).ifPresent( | |
| 372 | ( tab ) -> { | |
| 373 | tab.getTabPane().getTabs().remove( tab ); | |
| 374 | close( tab ); | |
| 375 | } | |
| 376 | ); | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * Answers whether the given {@link TextEditor} may be closed. | |
| 381 | * | |
| 382 | * @param editor The {@link TextEditor} to try closing. | |
| 383 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 384 | * the user has requested to keep the editor open. | |
| 385 | */ | |
| 386 | private boolean canClose( final TextEditor editor ) { | |
| 387 | final var editorTab = getTab( editor ); | |
| 388 | final var canClose = new AtomicBoolean( true ); | |
| 389 | ||
| 390 | if( editor.isModified() ) { | |
| 391 | final var filename = new StringBuilder(); | |
| 392 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 393 | ||
| 394 | final var message = sNotifier.createNotification( | |
| 395 | Messages.get( "Alert.file.close.title" ), | |
| 396 | Messages.get( "Alert.file.close.text" ), | |
| 397 | filename.toString() | |
| 398 | ); | |
| 399 | ||
| 400 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 401 | ||
| 402 | dialog.showAndWait().ifPresent( | |
| 403 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 404 | ); | |
| 405 | } | |
| 406 | ||
| 407 | return canClose.get(); | |
| 408 | } | |
| 409 | ||
| 410 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 411 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 412 | ||
| 413 | editor.addListener( ( c, o, n ) -> { | |
| 414 | if( n != null ) { | |
| 415 | mHtmlPreview.setBaseUri( n.getPath() ); | |
| 416 | process( n ); | |
| 417 | } | |
| 418 | } ); | |
| 419 | ||
| 420 | return editor; | |
| 421 | } | |
| 422 | ||
| 423 | /** | |
| 424 | * Adds the HTML preview tab to its own tab pane. This will only add the | |
| 425 | * preview once. | |
| 426 | */ | |
| 427 | public void viewPreview() { | |
| 428 | final var tabPane = obtainDetachableTabPane( TEXT_HTML ); | |
| 429 | ||
| 430 | // Prevent multiple HTML previews because in the end, there can be only one. | |
| 431 | for( final var tab : tabPane.getTabs() ) { | |
| 432 | if( tab.getContent() == mHtmlPreview ) { | |
| 433 | return; | |
| 434 | } | |
| 435 | } | |
| 436 | ||
| 437 | tabPane.addTab( "HTML", mHtmlPreview ); | |
| 438 | addTabPane( tabPane ); | |
| 439 | } | |
| 440 | ||
| 441 | public void viewRefresh() { | |
| 442 | mHtmlPreview.refresh(); | |
| 443 | } | |
| 444 | ||
| 445 | /** | |
| 446 | * Returns the tab that contains the given {@link TextEditor}. | |
| 447 | * | |
| 448 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 449 | * @return The first tab having content that matches the given tab. | |
| 450 | */ | |
| 451 | private Optional<Tab> getTab( final TextEditor editor ) { | |
| 452 | return mTabPanes.values() | |
| 453 | .stream() | |
| 454 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 455 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 456 | .findFirst(); | |
| 457 | } | |
| 458 | ||
| 459 | /** | |
| 460 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 461 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 462 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 463 | * text editor is refreshed. | |
| 464 | * | |
| 465 | * @param editor Text editor to update with the revised resolved map. | |
| 466 | * @return A newly configured property that represents the active | |
| 467 | * {@link DefinitionEditor}, never null. | |
| 468 | */ | |
| 469 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 470 | final ObjectProperty<TextEditor> editor ) { | |
| 471 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 472 | definitions.addListener( ( c, o, n ) -> { | |
| 473 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 474 | process( editor.get() ); | |
| 475 | } ); | |
| 476 | ||
| 477 | return definitions; | |
| 478 | } | |
| 479 | ||
| 480 | /** | |
| 481 | * Instantiates a factory that's responsible for creating new scenes when | |
| 482 | * a tab is dropped outside of any application window. The definition tabs | |
| 483 | * are fairly complex in that only one may be active at any time. When | |
| 484 | * activated, the {@link #mResolvedMap} must be updated to reflect the | |
| 485 | * hierarchy displayed in the {@link DefinitionEditor}. | |
| 486 | * | |
| 487 | * @param activeDefinitionEditor The current {@link DefinitionEditor}. | |
| 488 | * @return An object that listens to {@link DefinitionEditor} tab focus | |
| 489 | * changes. | |
| 490 | */ | |
| 491 | private DefinitionTabSceneFactory createDefinitionTabSceneFactory( | |
| 492 | final ObjectProperty<TextDefinition> activeDefinitionEditor ) { | |
| 493 | return new DefinitionTabSceneFactory( ( tab ) -> { | |
| 494 | assert tab != null; | |
| 495 | ||
| 496 | var node = tab.getContent(); | |
| 497 | if( node instanceof TextDefinition ) { | |
| 498 | activeDefinitionEditor.set( (DefinitionEditor) node ); | |
| 499 | } | |
| 500 | } ); | |
| 501 | } | |
| 502 | ||
| 503 | private DetachableTab createTab( final File file ) { | |
| 504 | final var r = createTextResource( file ); | |
| 505 | final var tab = new DetachableTab( r.getFilename(), r.getNode() ); | |
| 506 | ||
| 507 | r.modifiedProperty().addListener( | |
| 508 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 509 | ); | |
| 510 | ||
| 511 | // This is called when either the tab is closed by the user clicking on | |
| 512 | // the tab's close icon or when closing (all) from the file menu. | |
| 513 | tab.setOnClosed( | |
| 514 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 515 | ); | |
| 516 | ||
| 517 | return tab; | |
| 518 | } | |
| 519 | ||
| 520 | /** | |
| 521 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 522 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 523 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 524 | * be replaced by such a class. | |
| 525 | * <p> | |
| 526 | * When binning the files, this makes sure that at least one file exists | |
| 527 | * for every type. If the user has opted to close a particular type (such | |
| 528 | * as the definition pane), the view will suppressed elsewhere. | |
| 529 | * </p> | |
| 530 | * <p> | |
| 531 | * The order that the binned files are returned will be reflected in the | |
| 532 | * order that the corresponding panes are rendered in the UI. | |
| 533 | * </p> | |
| 534 | * | |
| 535 | * @param paths The file paths to bin according to their type. | |
| 536 | * @return An in-order list of files, first by structured definition files, | |
| 537 | * then by plain text documents. | |
| 538 | */ | |
| 539 | private List<File> bin( final SetProperty<String> paths ) { | |
| 540 | // Treat all files destined for the text editor as plain text documents | |
| 541 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 542 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 543 | final Function<MediaType, MediaType> bin = | |
| 544 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 545 | ||
| 546 | // Create two groups: YAML files and plain text files. | |
| 547 | final var bins = paths | |
| 548 | .stream() | |
| 549 | .collect( | |
| 550 | groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) ) | |
| 551 | ); | |
| 552 | ||
| 553 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 554 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 555 | ||
| 556 | final var result = new ArrayList<File>( paths.size() ); | |
| 557 | ||
| 558 | // Ensure that the same types are listed together (keep insertion order). | |
| 559 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 560 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 561 | ); | |
| 562 | ||
| 563 | return result; | |
| 564 | } | |
| 565 | ||
| 566 | /** | |
| 567 | * Uses the given {@link TextDefinition} instance to update the | |
| 568 | * {@link #mResolvedMap}. | |
| 569 | * | |
| 570 | * @param editor A non-null, possibly empty definition editor. | |
| 571 | */ | |
| 572 | private void resolve( final TextDefinition editor ) { | |
| 573 | assert editor != null; | |
| 574 | ||
| 575 | final var tokens = createDefinitionTokens(); | |
| 576 | final var operator = new YamlSigilOperator( tokens ); | |
| 577 | final var map = new HashMap<String, String>(); | |
| 578 | ||
| 579 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 580 | ||
| 581 | mResolvedMap.clear(); | |
| 582 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 583 | } | |
| 584 | ||
| 585 | /** | |
| 586 | * Force the active editor to update, which will cause the processor | |
| 587 | * to re-evaluate the interpolated definition map thereby updating the | |
| 588 | * preview pane. | |
| 589 | * | |
| 590 | * @param editor Contains the source document to update in the preview pane. | |
| 591 | */ | |
| 592 | private void process( final TextEditor editor ) { | |
| 593 | mProcessors.getOrDefault( editor, IdentityProcessor.INSTANCE ) | |
| 594 | .apply( editor == null ? "" : editor.getText() ); | |
| 595 | mHtmlPreview.scrollTo( CARET_ID ); | |
| 596 | } | |
| 597 | ||
| 598 | /** | |
| 599 | * Lazily creates a {@link DetachableTabPane} configured to handle focus | |
| 600 | * requests by delegating to the selected tab's content. The tab pane is | |
| 601 | * associated with a given media type so that similar files can be grouped | |
| 602 | * together. | |
| 603 | * | |
| 604 | * @param mediaType The media type to associate with the tab pane. | |
| 605 | * @return An instance of {@link DetachableTabPane} that will handle | |
| 606 | * docking of tabs. | |
| 607 | */ | |
| 608 | private DetachableTabPane obtainDetachableTabPane( | |
| 609 | final MediaType mediaType ) { | |
| 610 | return mTabPanes.computeIfAbsent( | |
| 611 | mediaType, ( mt ) -> createDetachableTabPane() | |
| 612 | ); | |
| 613 | } | |
| 614 | ||
| 615 | /** | |
| 616 | * Creates an initialized {@link DetachableTabPane} instance. | |
| 617 | * | |
| 618 | * @return A new {@link DetachableTabPane} with all listeners configured. | |
| 619 | */ | |
| 620 | private DetachableTabPane createDetachableTabPane() { | |
| 621 | final var tabPane = new DetachableTabPane(); | |
| 622 | ||
| 623 | initStageOwnerFactory( tabPane ); | |
| 624 | initTabListener( tabPane ); | |
| 625 | initSelectionModelListener( tabPane ); | |
| 626 | ||
| 627 | return tabPane; | |
| 628 | } | |
| 629 | ||
| 630 | /** | |
| 631 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 632 | * the stage owner factory must be given its parent window, which will | |
| 633 | * own the child window. The parent window is the {@link MainPane}'s | |
| 634 | * {@link Scene}'s {@link Window} instance. | |
| 635 | * | |
| 636 | * <p> | |
| 637 | * This will derives the new title from the main window title, incrementing | |
| 638 | * the window count to help uniquely identify the child windows. | |
| 639 | * </p> | |
| 640 | * | |
| 641 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 642 | */ | |
| 643 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 644 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 645 | final var title = get( | |
| 646 | "Detach.tab.title", | |
| 647 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 648 | ); | |
| 649 | stage.setTitle( title ); | |
| 650 | return getScene().getWindow(); | |
| 651 | } ); | |
| 652 | } | |
| 653 | ||
| 654 | /** | |
| 655 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 656 | * it is added to the given {@link DetachableTabPane} instance. | |
| 657 | * <p> | |
| 658 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 659 | * is initialized to perform synchronized scrolling between the editor and | |
| 660 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 661 | * tabs is given focus. | |
| 662 | * </p> | |
| 663 | * <p> | |
| 664 | * Note that multiple tabs can be added simultaneously. | |
| 665 | * </p> | |
| 666 | * | |
| 667 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 668 | */ | |
| 669 | private void initTabListener( final DetachableTabPane tabPane ) { | |
| 670 | tabPane.getTabs().addListener( | |
| 671 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 672 | while( listener.next() ) { | |
| 673 | if( listener.wasAdded() ) { | |
| 674 | final var tabs = listener.getAddedSubList(); | |
| 675 | ||
| 676 | tabs.forEach( ( tab ) -> { | |
| 677 | final var node = tab.getContent(); | |
| 678 | ||
| 679 | if( node instanceof TextEditor ) { | |
| 680 | initScrollEventListener( tab ); | |
| 681 | } | |
| 682 | } ); | |
| 683 | ||
| 684 | // Select and give focus to the last tab opened. | |
| 685 | final var index = tabs.size() - 1; | |
| 686 | if( index >= 0 ) { | |
| 687 | final var tab = tabs.get( index ); | |
| 688 | tabPane.getSelectionModel().select( tab ); | |
| 689 | tab.getContent().requestFocus(); | |
| 690 | } | |
| 691 | } | |
| 692 | } | |
| 693 | } | |
| 694 | ); | |
| 695 | } | |
| 696 | ||
| 697 | /** | |
| 698 | * Responsible for handling tab change events. | |
| 699 | * | |
| 700 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 701 | */ | |
| 702 | private void initSelectionModelListener( final DetachableTabPane tabPane ) { | |
| 703 | final var model = tabPane.getSelectionModel(); | |
| 704 | ||
| 705 | model.selectedItemProperty().addListener( ( c, o, n ) -> { | |
| 706 | if( o != null && n == null ) { | |
| 707 | final var node = o.getContent(); | |
| 708 | ||
| 709 | // If the last definition editor in the active pane was closed, | |
| 710 | // clear out the definitions then refresh the text editor. | |
| 711 | if( node instanceof TextDefinition ) { | |
| 712 | mActiveDefinitionEditor.set( createDefinitionEditor() ); | |
| 713 | } | |
| 714 | } | |
| 715 | else if( n != null ) { | |
| 716 | final var node = n.getContent(); | |
| 717 | ||
| 718 | if( node instanceof TextEditor ) { | |
| 719 | // Changing the active node will fire an event, which will | |
| 720 | // update the preview panel and grab focus. | |
| 721 | mActiveTextEditor.set( (TextEditor) node ); | |
| 722 | runLater( node::requestFocus ); | |
| 723 | } | |
| 724 | else if( node instanceof TextDefinition ) { | |
| 725 | mActiveDefinitionEditor.set( (DefinitionEditor) node ); | |
| 726 | } | |
| 727 | } | |
| 728 | } ); | |
| 729 | } | |
| 730 | ||
| 731 | /** | |
| 732 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 733 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 734 | * | |
| 735 | * @param tab The container for an instance of {@link TextEditor}. | |
| 736 | */ | |
| 737 | private void initScrollEventListener( final Tab tab ) { | |
| 738 | final var editor = (TextEditor) tab.getContent(); | |
| 739 | final var scrollPane = editor.getScrollPane(); | |
| 740 | final var scrollBar = mHtmlPreview.getVerticalScrollBar(); | |
| 741 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 742 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 743 | } | |
| 744 | ||
| 745 | private void addTabPane( final int index, final DetachableTabPane tabPane ) { | |
| 746 | final var items = getItems(); | |
| 747 | if( !items.contains( tabPane ) ) { | |
| 748 | items.add( index, tabPane ); | |
| 749 | } | |
| 750 | } | |
| 751 | ||
| 752 | private void addTabPane( final DetachableTabPane tabPane ) { | |
| 753 | addTabPane( getItems().size(), tabPane ); | |
| 754 | } | |
| 755 | ||
| 756 | /** | |
| 757 | * @param path Used by {@link ProcessorFactory} to determine | |
| 758 | * {@link Processor} type to create based on file type. | |
| 759 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 760 | * preview document for scrollbar synchronization. | |
| 761 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 762 | * {@link Processor}. | |
| 763 | */ | |
| 764 | private ProcessorContext createProcessorContext( | |
| 765 | final Path path, final Caret caret ) { | |
| 766 | return new ProcessorContext( | |
| 767 | mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace | |
| 768 | ); | |
| 769 | } | |
| 770 | ||
| 771 | public ProcessorContext createProcessorContext( final TextEditor t ) { | |
| 772 | return createProcessorContext( t.getPath(), t.getCaret() ); | |
| 773 | } | |
| 774 | ||
| 775 | private TextResource createTextResource( final File file ) { | |
| 776 | // TODO: Create PlainTextEditor that's returned by default. | |
| 777 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 778 | ? createDefinitionEditor( file ) | |
| 779 | : createMarkdownEditor( file ); | |
| 780 | } | |
| 781 | ||
| 782 | /** | |
| 783 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 784 | * caret change events and text change events. Text change events must | |
| 785 | * take priority over caret change events because it's possible to change | |
| 786 | * the text without moving the caret (e.g., delete selected text). | |
| 787 | * | |
| 788 | * @param file The file containing contents for the text editor. | |
| 789 | * @return A non-null text editor. | |
| 790 | */ | |
| 791 | private TextResource createMarkdownEditor( final File file ) { | |
| 792 | final var path = file.toPath(); | |
| 793 | final var editor = new MarkdownEditor( file, getWorkspace() ); | |
| 794 | final var caret = editor.getCaret(); | |
| 795 | final var context = createProcessorContext( path, caret ); | |
| 796 | ||
| 797 | mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | |
| 798 | ||
| 799 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 800 | if( n ) { | |
| 801 | // Reset the status to OK after changing the text. | |
| 802 | clue(); | |
| 803 | ||
| 804 | // Processing the text will update the status bar. | |
| 805 | process( getActiveTextEditor() ); | |
| 806 | } | |
| 807 | } ); | |
| 808 | ||
| 809 | editor.addEventListener( | |
| 810 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 811 | ); | |
| 812 | ||
| 813 | // Set the active editor, which refreshes the preview panel. | |
| 814 | mActiveTextEditor.set( editor ); | |
| 815 | ||
| 816 | return editor; | |
| 817 | } | |
| 818 | ||
| 819 | /** | |
| 820 | * Delegates to {@link #autoinsert()}. | |
| 821 | * | |
| 822 | * @param event Ignored. | |
| 823 | */ | |
| 824 | private void autoinsert( final KeyEvent event ) { | |
| 825 | autoinsert(); | |
| 826 | } | |
| 827 | ||
| 828 | /** | |
| 829 | * Finds a node that matches the word at the caret, then inserts the | |
| 830 | * corresponding definition. The definition token delimiters depend on | |
| 831 | * the type of file being edited. | |
| 832 | */ | |
| 833 | public void autoinsert() { | |
| 834 | final var definitions = getActiveTextDefinition(); | |
| 835 | final var editor = getActiveTextEditor(); | |
| 836 | final var mediaType = editor.getMediaType(); | |
| 837 | final var operator = getSigilOperator( mediaType ); | |
| 838 | ||
| 839 | DefinitionNameInjector.autoinsert( editor, definitions, operator ); | |
| 840 | } | |
| 841 | ||
| 842 | private TextDefinition createDefinitionEditor() { | |
| 843 | return createDefinitionEditor( DEFINITION_DEFAULT ); | |
| 844 | } | |
| 845 | ||
| 846 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 847 | final var transformer = createTreeTransformer(); | |
| 848 | final var editor = new DefinitionEditor( file, transformer ); | |
| 849 | ||
| 850 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 851 | ||
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.editors.TextDefinition; | |
| 5 | import com.keenwrite.editors.TextEditor; | |
| 6 | import com.keenwrite.editors.TextResource; | |
| 7 | import com.keenwrite.editors.definition.DefinitionEditor; | |
| 8 | import com.keenwrite.editors.definition.DefinitionTabSceneFactory; | |
| 9 | import com.keenwrite.editors.definition.TreeTransformer; | |
| 10 | import com.keenwrite.editors.definition.yaml.YamlTreeTransformer; | |
| 11 | import com.keenwrite.editors.markdown.MarkdownEditor; | |
| 12 | import com.keenwrite.io.MediaType; | |
| 13 | import com.keenwrite.preferences.Key; | |
| 14 | import com.keenwrite.preferences.Workspace; | |
| 15 | import com.keenwrite.preview.HtmlPreview; | |
| 16 | import com.keenwrite.processors.IdentityProcessor; | |
| 17 | import com.keenwrite.processors.Processor; | |
| 18 | import com.keenwrite.processors.ProcessorContext; | |
| 19 | import com.keenwrite.processors.ProcessorFactory; | |
| 20 | import com.keenwrite.processors.markdown.extensions.caret.CaretExtension; | |
| 21 | import com.keenwrite.service.events.Notifier; | |
| 22 | import com.keenwrite.sigils.RSigilOperator; | |
| 23 | import com.keenwrite.sigils.SigilOperator; | |
| 24 | import com.keenwrite.sigils.Tokens; | |
| 25 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 26 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 27 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 28 | import javafx.beans.property.*; | |
| 29 | import javafx.collections.ListChangeListener; | |
| 30 | import javafx.event.ActionEvent; | |
| 31 | import javafx.event.Event; | |
| 32 | import javafx.event.EventHandler; | |
| 33 | import javafx.scene.Scene; | |
| 34 | import javafx.scene.control.SplitPane; | |
| 35 | import javafx.scene.control.Tab; | |
| 36 | import javafx.scene.control.Tooltip; | |
| 37 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 38 | import javafx.scene.input.KeyEvent; | |
| 39 | import javafx.stage.Stage; | |
| 40 | import javafx.stage.Window; | |
| 41 | ||
| 42 | import java.io.File; | |
| 43 | import java.nio.file.Path; | |
| 44 | import java.util.*; | |
| 45 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 46 | import java.util.function.Function; | |
| 47 | import java.util.stream.Collectors; | |
| 48 | ||
| 49 | import static com.keenwrite.Constants.*; | |
| 50 | import static com.keenwrite.ExportFormat.NONE; | |
| 51 | import static com.keenwrite.Messages.get; | |
| 52 | import static com.keenwrite.StatusNotifier.clue; | |
| 53 | import static com.keenwrite.io.MediaType.*; | |
| 54 | import static com.keenwrite.preferences.Workspace.*; | |
| 55 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 56 | import static com.keenwrite.service.events.Notifier.NO; | |
| 57 | import static com.keenwrite.service.events.Notifier.YES; | |
| 58 | import static java.util.stream.Collectors.groupingBy; | |
| 59 | import static javafx.application.Platform.runLater; | |
| 60 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 61 | import static javafx.scene.input.KeyCode.SPACE; | |
| 62 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 63 | import static javafx.util.Duration.millis; | |
| 64 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 65 | ||
| 66 | /** | |
| 67 | * Responsible for wiring together the main application components for a | |
| 68 | * particular workspace (project). These include the definition views, | |
| 69 | * text editors, and preview pane along with any corresponding controllers. | |
| 70 | */ | |
| 71 | public final class MainPane extends SplitPane { | |
| 72 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 73 | ||
| 74 | /** | |
| 75 | * Used when opening files to determine how each file should be binned and | |
| 76 | * therefore what tab pane to be opened within. | |
| 77 | */ | |
| 78 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 79 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED | |
| 80 | ); | |
| 81 | ||
| 82 | /** | |
| 83 | * Prevents re-instantiation of processing classes. | |
| 84 | */ | |
| 85 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 86 | new HashMap<>(); | |
| 87 | ||
| 88 | private final Workspace mWorkspace; | |
| 89 | ||
| 90 | /** | |
| 91 | * Groups similar file type tabs together. | |
| 92 | */ | |
| 93 | private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>(); | |
| 94 | ||
| 95 | /** | |
| 96 | * Stores definition names and values. | |
| 97 | */ | |
| 98 | private final Map<String, String> mResolvedMap = | |
| 99 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 100 | ||
| 101 | /** | |
| 102 | * Renders the actively selected plain text editor tab. | |
| 103 | */ | |
| 104 | private final HtmlPreview mHtmlPreview; | |
| 105 | ||
| 106 | /** | |
| 107 | * Changing the active editor fires the value changed event. This allows | |
| 108 | * refreshes to happen when external definitions are modified and need to | |
| 109 | * trigger the processing chain. | |
| 110 | */ | |
| 111 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 112 | createActiveTextEditor(); | |
| 113 | ||
| 114 | /** | |
| 115 | * Changing the active definition editor fires the value changed event. This | |
| 116 | * allows refreshes to happen when external definitions are modified and need | |
| 117 | * to trigger the processing chain. | |
| 118 | */ | |
| 119 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 120 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 121 | ||
| 122 | /** | |
| 123 | * Responsible for creating a new scene when a tab is detached into | |
| 124 | * its own window frame. | |
| 125 | */ | |
| 126 | private final DefinitionTabSceneFactory mDefinitionTabSceneFactory = | |
| 127 | createDefinitionTabSceneFactory( mActiveDefinitionEditor ); | |
| 128 | ||
| 129 | /** | |
| 130 | * Tracks the number of detached tab panels opened into their own windows, | |
| 131 | * which allows unique identification of subordinate windows by their title. | |
| 132 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 133 | */ | |
| 134 | private byte mWindowCount; | |
| 135 | ||
| 136 | /** | |
| 137 | * Called when the definition data is changed. | |
| 138 | */ | |
| 139 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 140 | event -> { | |
| 141 | final var editor = mActiveDefinitionEditor.get(); | |
| 142 | ||
| 143 | resolve( editor ); | |
| 144 | process( getActiveTextEditor() ); | |
| 145 | save( editor ); | |
| 146 | }; | |
| 147 | ||
| 148 | /** | |
| 149 | * Adds all content panels to the main user interface. This will load the | |
| 150 | * configuration settings from the workspace to reproduce the settings from | |
| 151 | * a previous session. | |
| 152 | */ | |
| 153 | public MainPane( final Workspace workspace ) { | |
| 154 | mWorkspace = workspace; | |
| 155 | mHtmlPreview = new HtmlPreview( workspace ); | |
| 156 | ||
| 157 | open( bin( getRecentFiles() ) ); | |
| 158 | viewPreview(); | |
| 159 | setDividerPositions( calculateDividerPositions() ); | |
| 160 | ||
| 161 | // Once the main scene's window regains focus, update the active definition | |
| 162 | // editor to the currently selected tab. | |
| 163 | runLater( | |
| 164 | () -> getWindow().focusedProperty().addListener( ( c, o, n ) -> { | |
| 165 | if( n != null && n ) { | |
| 166 | final var pane = mTabPanes.get( TEXT_YAML ); | |
| 167 | final var model = pane.getSelectionModel(); | |
| 168 | final var tab = model.getSelectedItem(); | |
| 169 | ||
| 170 | if( tab != null ) { | |
| 171 | final var resource = tab.getContent(); | |
| 172 | ||
| 173 | if( resource instanceof TextDefinition ) { | |
| 174 | mActiveDefinitionEditor.set( (TextDefinition) tab.getContent() ); | |
| 175 | } | |
| 176 | } | |
| 177 | } | |
| 178 | } ) | |
| 179 | ); | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 184 | */ | |
| 185 | private double[] calculateDividerPositions() { | |
| 186 | final var ratio = 100f / getItems().size() / 100; | |
| 187 | final var positions = getDividerPositions(); | |
| 188 | ||
| 189 | for( int i = 0; i < positions.length; i++ ) { | |
| 190 | positions[ i ] = ratio * i; | |
| 191 | } | |
| 192 | ||
| 193 | return positions; | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Opens all the files into the application, provided the paths are unique. | |
| 198 | * This may only be called for any type of files that a user can edit | |
| 199 | * (i.e., update and persist), such as definitions and text files. | |
| 200 | * | |
| 201 | * @param files The list of files to open. | |
| 202 | */ | |
| 203 | public void open( final List<File> files ) { | |
| 204 | files.forEach( this::open ); | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * This opens the given file. Since the preview pane is not a file that | |
| 209 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 210 | * | |
| 211 | * @param file The file to open. | |
| 212 | */ | |
| 213 | private void open( final File file ) { | |
| 214 | final var tab = createTab( file ); | |
| 215 | final var node = tab.getContent(); | |
| 216 | final var mediaType = MediaType.valueFrom( file ); | |
| 217 | final var tabPane = obtainDetachableTabPane( mediaType ); | |
| 218 | final var newTabPane = !getItems().contains( tabPane ); | |
| 219 | ||
| 220 | tab.setTooltip( createTooltip( file ) ); | |
| 221 | tabPane.setFocusTraversable( false ); | |
| 222 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 223 | tabPane.getTabs().add( tab ); | |
| 224 | ||
| 225 | if( newTabPane ) { | |
| 226 | var index = getItems().size(); | |
| 227 | ||
| 228 | if( node instanceof TextDefinition ) { | |
| 229 | tabPane.setSceneFactory( mDefinitionTabSceneFactory::create ); | |
| 230 | index = 0; | |
| 231 | } | |
| 232 | ||
| 233 | addTabPane( index, tabPane ); | |
| 234 | } | |
| 235 | ||
| 236 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Opens a new text editor document using the default document file name. | |
| 241 | */ | |
| 242 | public void newTextEditor() { | |
| 243 | open( DOCUMENT_DEFAULT ); | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Opens a new definition editor document using the default definition | |
| 248 | * file name. | |
| 249 | */ | |
| 250 | public void newDefinitionEditor() { | |
| 251 | open( DEFINITION_DEFAULT ); | |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 256 | * that they save themselves. | |
| 257 | */ | |
| 258 | public void saveAll() { | |
| 259 | mTabPanes.forEach( | |
| 260 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 261 | final var node = tab.getContent(); | |
| 262 | if( node instanceof TextEditor ) { | |
| 263 | save( ((TextEditor) node) ); | |
| 264 | } | |
| 265 | } ) | |
| 266 | ); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 271 | * checking if modified first because if the user swaps external media from | |
| 272 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 273 | * the user: save always re-saves. Also, it's less code. | |
| 274 | */ | |
| 275 | public void save() { | |
| 276 | save( getActiveTextEditor() ); | |
| 277 | } | |
| 278 | ||
| 279 | /** | |
| 280 | * Saves the active {@link TextEditor} under a new name. | |
| 281 | * | |
| 282 | * @param file The new active editor {@link File} reference. | |
| 283 | */ | |
| 284 | public void saveAs( final File file ) { | |
| 285 | assert file != null; | |
| 286 | final var editor = getActiveTextEditor(); | |
| 287 | final var tab = getTab( editor ); | |
| 288 | ||
| 289 | editor.rename( file ); | |
| 290 | tab.ifPresent( t -> { | |
| 291 | t.setText( editor.getFilename() ); | |
| 292 | t.setTooltip( createTooltip( file ) ); | |
| 293 | } ); | |
| 294 | ||
| 295 | save(); | |
| 296 | } | |
| 297 | ||
| 298 | /** | |
| 299 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 300 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 301 | * | |
| 302 | * @param resource The resource to export. | |
| 303 | */ | |
| 304 | private void save( final TextResource resource ) { | |
| 305 | try { | |
| 306 | resource.save(); | |
| 307 | } catch( final Exception ex ) { | |
| 308 | clue( ex ); | |
| 309 | sNotifier.alert( | |
| 310 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 311 | ); | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | /** | |
| 316 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 317 | * | |
| 318 | * @return {@code true} when all editors, modified or otherwise, were | |
| 319 | * permitted to close; {@code false} when one or more editors were modified | |
| 320 | * and the user requested no closing. | |
| 321 | */ | |
| 322 | public boolean closeAll() { | |
| 323 | var closable = true; | |
| 324 | ||
| 325 | for( final var entry : mTabPanes.entrySet() ) { | |
| 326 | final var tabPane = entry.getValue(); | |
| 327 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 328 | ||
| 329 | while( tabIterator.hasNext() ) { | |
| 330 | final var tab = tabIterator.next(); | |
| 331 | final var node = tab.getContent(); | |
| 332 | ||
| 333 | if( node instanceof TextEditor && | |
| 334 | (closable &= canClose( (TextEditor) node )) ) { | |
| 335 | tabIterator.remove(); | |
| 336 | close( tab ); | |
| 337 | } | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | return closable; | |
| 342 | } | |
| 343 | ||
| 344 | /** | |
| 345 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 346 | * event. | |
| 347 | * | |
| 348 | * @param tab The {@link Tab} that was closed. | |
| 349 | */ | |
| 350 | private void close( final Tab tab ) { | |
| 351 | final var handler = tab.getOnClosed(); | |
| 352 | ||
| 353 | if( handler != null ) { | |
| 354 | handler.handle( new ActionEvent() ); | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | /** | |
| 359 | * Closes the active tab; delegates to {@link #canClose(TextEditor)}. | |
| 360 | */ | |
| 361 | public void close() { | |
| 362 | final var editor = getActiveTextEditor(); | |
| 363 | if( canClose( editor ) ) { | |
| 364 | close( editor ); | |
| 365 | } | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Closes the given {@link TextEditor}. This must not be called from within | |
| 370 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 371 | * concurrent modification exception be thrown. | |
| 372 | * | |
| 373 | * @param editor The {@link TextEditor} to close, without confirming with | |
| 374 | * the user. | |
| 375 | */ | |
| 376 | private void close( final TextEditor editor ) { | |
| 377 | getTab( editor ).ifPresent( | |
| 378 | ( tab ) -> { | |
| 379 | tab.getTabPane().getTabs().remove( tab ); | |
| 380 | close( tab ); | |
| 381 | } | |
| 382 | ); | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Answers whether the given {@link TextEditor} may be closed. | |
| 387 | * | |
| 388 | * @param editor The {@link TextEditor} to try closing. | |
| 389 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 390 | * the user has requested to keep the editor open. | |
| 391 | */ | |
| 392 | private boolean canClose( final TextEditor editor ) { | |
| 393 | final var editorTab = getTab( editor ); | |
| 394 | final var canClose = new AtomicBoolean( true ); | |
| 395 | ||
| 396 | if( editor.isModified() ) { | |
| 397 | final var filename = new StringBuilder(); | |
| 398 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 399 | ||
| 400 | final var message = sNotifier.createNotification( | |
| 401 | Messages.get( "Alert.file.close.title" ), | |
| 402 | Messages.get( "Alert.file.close.text" ), | |
| 403 | filename.toString() | |
| 404 | ); | |
| 405 | ||
| 406 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 407 | ||
| 408 | dialog.showAndWait().ifPresent( | |
| 409 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 410 | ); | |
| 411 | } | |
| 412 | ||
| 413 | return canClose.get(); | |
| 414 | } | |
| 415 | ||
| 416 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 417 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 418 | ||
| 419 | editor.addListener( ( c, o, n ) -> { | |
| 420 | if( n != null ) { | |
| 421 | mHtmlPreview.setBaseUri( n.getPath() ); | |
| 422 | process( n ); | |
| 423 | } | |
| 424 | } ); | |
| 425 | ||
| 426 | return editor; | |
| 427 | } | |
| 428 | ||
| 429 | /** | |
| 430 | * Adds the HTML preview tab to its own tab pane. This will only add the | |
| 431 | * preview once. | |
| 432 | */ | |
| 433 | public void viewPreview() { | |
| 434 | final var tabPane = obtainDetachableTabPane( TEXT_HTML ); | |
| 435 | ||
| 436 | // Prevent multiple HTML previews because in the end, there can be only one. | |
| 437 | for( final var tab : tabPane.getTabs() ) { | |
| 438 | if( tab.getContent() == mHtmlPreview ) { | |
| 439 | return; | |
| 440 | } | |
| 441 | } | |
| 442 | ||
| 443 | tabPane.addTab( "HTML", mHtmlPreview ); | |
| 444 | addTabPane( tabPane ); | |
| 445 | } | |
| 446 | ||
| 447 | public void viewRefresh() { | |
| 448 | mHtmlPreview.refresh(); | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * Returns the tab that contains the given {@link TextEditor}. | |
| 453 | * | |
| 454 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 455 | * @return The first tab having content that matches the given tab. | |
| 456 | */ | |
| 457 | private Optional<Tab> getTab( final TextEditor editor ) { | |
| 458 | return mTabPanes.values() | |
| 459 | .stream() | |
| 460 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 461 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 462 | .findFirst(); | |
| 463 | } | |
| 464 | ||
| 465 | /** | |
| 466 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 467 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 468 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 469 | * text editor is refreshed. | |
| 470 | * | |
| 471 | * @param editor Text editor to update with the revised resolved map. | |
| 472 | * @return A newly configured property that represents the active | |
| 473 | * {@link DefinitionEditor}, never null. | |
| 474 | */ | |
| 475 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 476 | final ObjectProperty<TextEditor> editor ) { | |
| 477 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 478 | definitions.addListener( ( c, o, n ) -> { | |
| 479 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 480 | process( editor.get() ); | |
| 481 | } ); | |
| 482 | ||
| 483 | return definitions; | |
| 484 | } | |
| 485 | ||
| 486 | /** | |
| 487 | * Instantiates a factory that's responsible for creating new scenes when | |
| 488 | * a tab is dropped outside of any application window. The definition tabs | |
| 489 | * are fairly complex in that only one may be active at any time. When | |
| 490 | * activated, the {@link #mResolvedMap} must be updated to reflect the | |
| 491 | * hierarchy displayed in the {@link DefinitionEditor}. | |
| 492 | * | |
| 493 | * @param activeDefinitionEditor The current {@link DefinitionEditor}. | |
| 494 | * @return An object that listens to {@link DefinitionEditor} tab focus | |
| 495 | * changes. | |
| 496 | */ | |
| 497 | private DefinitionTabSceneFactory createDefinitionTabSceneFactory( | |
| 498 | final ObjectProperty<TextDefinition> activeDefinitionEditor ) { | |
| 499 | return new DefinitionTabSceneFactory( ( tab ) -> { | |
| 500 | assert tab != null; | |
| 501 | ||
| 502 | var node = tab.getContent(); | |
| 503 | if( node instanceof TextDefinition ) { | |
| 504 | activeDefinitionEditor.set( (DefinitionEditor) node ); | |
| 505 | } | |
| 506 | } ); | |
| 507 | } | |
| 508 | ||
| 509 | private DetachableTab createTab( final File file ) { | |
| 510 | final var r = createTextResource( file ); | |
| 511 | final var tab = new DetachableTab( r.getFilename(), r.getNode() ); | |
| 512 | ||
| 513 | r.modifiedProperty().addListener( | |
| 514 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 515 | ); | |
| 516 | ||
| 517 | // This is called when either the tab is closed by the user clicking on | |
| 518 | // the tab's close icon or when closing (all) from the file menu. | |
| 519 | tab.setOnClosed( | |
| 520 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 521 | ); | |
| 522 | ||
| 523 | return tab; | |
| 524 | } | |
| 525 | ||
| 526 | /** | |
| 527 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 528 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 529 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 530 | * be replaced by such a class. | |
| 531 | * <p> | |
| 532 | * When binning the files, this makes sure that at least one file exists | |
| 533 | * for every type. If the user has opted to close a particular type (such | |
| 534 | * as the definition pane), the view will suppressed elsewhere. | |
| 535 | * </p> | |
| 536 | * <p> | |
| 537 | * The order that the binned files are returned will be reflected in the | |
| 538 | * order that the corresponding panes are rendered in the UI. | |
| 539 | * </p> | |
| 540 | * | |
| 541 | * @param paths The file paths to bin according to their type. | |
| 542 | * @return An in-order list of files, first by structured definition files, | |
| 543 | * then by plain text documents. | |
| 544 | */ | |
| 545 | private List<File> bin( final SetProperty<String> paths ) { | |
| 546 | // Treat all files destined for the text editor as plain text documents | |
| 547 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 548 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 549 | final Function<MediaType, MediaType> bin = | |
| 550 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 551 | ||
| 552 | // Create two groups: YAML files and plain text files. | |
| 553 | final var bins = paths | |
| 554 | .stream() | |
| 555 | .collect( | |
| 556 | groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) ) | |
| 557 | ); | |
| 558 | ||
| 559 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 560 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 561 | ||
| 562 | final var result = new ArrayList<File>( paths.size() ); | |
| 563 | ||
| 564 | // Ensure that the same types are listed together (keep insertion order). | |
| 565 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 566 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 567 | ); | |
| 568 | ||
| 569 | return result; | |
| 570 | } | |
| 571 | ||
| 572 | /** | |
| 573 | * Uses the given {@link TextDefinition} instance to update the | |
| 574 | * {@link #mResolvedMap}. | |
| 575 | * | |
| 576 | * @param editor A non-null, possibly empty definition editor. | |
| 577 | */ | |
| 578 | private void resolve( final TextDefinition editor ) { | |
| 579 | assert editor != null; | |
| 580 | ||
| 581 | final var tokens = createDefinitionTokens(); | |
| 582 | final var operator = new YamlSigilOperator( tokens ); | |
| 583 | final var map = new HashMap<String, String>(); | |
| 584 | ||
| 585 | editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) ); | |
| 586 | ||
| 587 | mResolvedMap.clear(); | |
| 588 | mResolvedMap.putAll( editor.interpolate( map, tokens ) ); | |
| 589 | } | |
| 590 | ||
| 591 | /** | |
| 592 | * Force the active editor to update, which will cause the processor | |
| 593 | * to re-evaluate the interpolated definition map thereby updating the | |
| 594 | * preview pane. | |
| 595 | * | |
| 596 | * @param editor Contains the source document to update in the preview pane. | |
| 597 | */ | |
| 598 | private void process( final TextEditor editor ) { | |
| 599 | mProcessors.getOrDefault( editor, IdentityProcessor.IDENTITY ) | |
| 600 | .apply( editor == null ? "" : editor.getText() ); | |
| 601 | mHtmlPreview.scrollTo( CARET_ID ); | |
| 602 | } | |
| 603 | ||
| 604 | /** | |
| 605 | * Lazily creates a {@link DetachableTabPane} configured to handle focus | |
| 606 | * requests by delegating to the selected tab's content. The tab pane is | |
| 607 | * associated with a given media type so that similar files can be grouped | |
| 608 | * together. | |
| 609 | * | |
| 610 | * @param mediaType The media type to associate with the tab pane. | |
| 611 | * @return An instance of {@link DetachableTabPane} that will handle | |
| 612 | * docking of tabs. | |
| 613 | */ | |
| 614 | private DetachableTabPane obtainDetachableTabPane( | |
| 615 | final MediaType mediaType ) { | |
| 616 | return mTabPanes.computeIfAbsent( | |
| 617 | mediaType, ( mt ) -> createDetachableTabPane() | |
| 618 | ); | |
| 619 | } | |
| 620 | ||
| 621 | /** | |
| 622 | * Creates an initialized {@link DetachableTabPane} instance. | |
| 623 | * | |
| 624 | * @return A new {@link DetachableTabPane} with all listeners configured. | |
| 625 | */ | |
| 626 | private DetachableTabPane createDetachableTabPane() { | |
| 627 | final var tabPane = new DetachableTabPane(); | |
| 628 | ||
| 629 | initStageOwnerFactory( tabPane ); | |
| 630 | initTabListener( tabPane ); | |
| 631 | initSelectionModelListener( tabPane ); | |
| 632 | ||
| 633 | return tabPane; | |
| 634 | } | |
| 635 | ||
| 636 | /** | |
| 637 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 638 | * the stage owner factory must be given its parent window, which will | |
| 639 | * own the child window. The parent window is the {@link MainPane}'s | |
| 640 | * {@link Scene}'s {@link Window} instance. | |
| 641 | * | |
| 642 | * <p> | |
| 643 | * This will derives the new title from the main window title, incrementing | |
| 644 | * the window count to help uniquely identify the child windows. | |
| 645 | * </p> | |
| 646 | * | |
| 647 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 648 | */ | |
| 649 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 650 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 651 | final var title = get( | |
| 652 | "Detach.tab.title", | |
| 653 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 654 | ); | |
| 655 | stage.setTitle( title ); | |
| 656 | return getScene().getWindow(); | |
| 657 | } ); | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 662 | * it is added to the given {@link DetachableTabPane} instance. | |
| 663 | * <p> | |
| 664 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 665 | * is initialized to perform synchronized scrolling between the editor and | |
| 666 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 667 | * tabs is given focus. | |
| 668 | * </p> | |
| 669 | * <p> | |
| 670 | * Note that multiple tabs can be added simultaneously. | |
| 671 | * </p> | |
| 672 | * | |
| 673 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 674 | */ | |
| 675 | private void initTabListener( final DetachableTabPane tabPane ) { | |
| 676 | tabPane.getTabs().addListener( | |
| 677 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 678 | while( listener.next() ) { | |
| 679 | if( listener.wasAdded() ) { | |
| 680 | final var tabs = listener.getAddedSubList(); | |
| 681 | ||
| 682 | tabs.forEach( ( tab ) -> { | |
| 683 | final var node = tab.getContent(); | |
| 684 | ||
| 685 | if( node instanceof TextEditor ) { | |
| 686 | initScrollEventListener( tab ); | |
| 687 | } | |
| 688 | } ); | |
| 689 | ||
| 690 | // Select and give focus to the last tab opened. | |
| 691 | final var index = tabs.size() - 1; | |
| 692 | if( index >= 0 ) { | |
| 693 | final var tab = tabs.get( index ); | |
| 694 | tabPane.getSelectionModel().select( tab ); | |
| 695 | tab.getContent().requestFocus(); | |
| 696 | } | |
| 697 | } | |
| 698 | } | |
| 699 | } | |
| 700 | ); | |
| 701 | } | |
| 702 | ||
| 703 | /** | |
| 704 | * Responsible for handling tab change events. | |
| 705 | * | |
| 706 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 707 | */ | |
| 708 | private void initSelectionModelListener( final DetachableTabPane tabPane ) { | |
| 709 | final var model = tabPane.getSelectionModel(); | |
| 710 | ||
| 711 | model.selectedItemProperty().addListener( ( c, o, n ) -> { | |
| 712 | if( o != null && n == null ) { | |
| 713 | final var node = o.getContent(); | |
| 714 | ||
| 715 | // If the last definition editor in the active pane was closed, | |
| 716 | // clear out the definitions then refresh the text editor. | |
| 717 | if( node instanceof TextDefinition ) { | |
| 718 | mActiveDefinitionEditor.set( createDefinitionEditor() ); | |
| 719 | } | |
| 720 | } | |
| 721 | else if( n != null ) { | |
| 722 | final var node = n.getContent(); | |
| 723 | ||
| 724 | if( node instanceof TextEditor ) { | |
| 725 | // Changing the active node will fire an event, which will | |
| 726 | // update the preview panel and grab focus. | |
| 727 | mActiveTextEditor.set( (TextEditor) node ); | |
| 728 | runLater( node::requestFocus ); | |
| 729 | } | |
| 730 | else if( node instanceof TextDefinition ) { | |
| 731 | mActiveDefinitionEditor.set( (DefinitionEditor) node ); | |
| 732 | } | |
| 733 | } | |
| 734 | } ); | |
| 735 | } | |
| 736 | ||
| 737 | /** | |
| 738 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 739 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 740 | * | |
| 741 | * @param tab The container for an instance of {@link TextEditor}. | |
| 742 | */ | |
| 743 | private void initScrollEventListener( final Tab tab ) { | |
| 744 | final var editor = (TextEditor) tab.getContent(); | |
| 745 | final var scrollPane = editor.getScrollPane(); | |
| 746 | final var scrollBar = mHtmlPreview.getVerticalScrollBar(); | |
| 747 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 748 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 749 | } | |
| 750 | ||
| 751 | private void addTabPane( final int index, final DetachableTabPane tabPane ) { | |
| 752 | final var items = getItems(); | |
| 753 | if( !items.contains( tabPane ) ) { | |
| 754 | items.add( index, tabPane ); | |
| 755 | } | |
| 756 | } | |
| 757 | ||
| 758 | private void addTabPane( final DetachableTabPane tabPane ) { | |
| 759 | addTabPane( getItems().size(), tabPane ); | |
| 760 | } | |
| 761 | ||
| 762 | public ProcessorContext createProcessorContext() { | |
| 763 | final var editor = getActiveTextEditor(); | |
| 764 | return createProcessorContext( editor.getPath(), editor.getCaret() ); | |
| 765 | } | |
| 766 | ||
| 767 | /** | |
| 768 | * @param path Used by {@link ProcessorFactory} to determine | |
| 769 | * {@link Processor} type to create based on file type. | |
| 770 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 771 | * preview document for scrollbar synchronization. | |
| 772 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 773 | * {@link Processor}. | |
| 774 | */ | |
| 775 | private ProcessorContext createProcessorContext( | |
| 776 | final Path path, final Caret caret ) { | |
| 777 | return new ProcessorContext( | |
| 778 | mHtmlPreview, mResolvedMap, path, caret, NONE, mWorkspace | |
| 779 | ); | |
| 780 | } | |
| 781 | ||
| 782 | private TextResource createTextResource( final File file ) { | |
| 783 | // TODO: Create PlainTextEditor that's returned by default. | |
| 784 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 785 | ? createDefinitionEditor( file ) | |
| 786 | : createMarkdownEditor( file ); | |
| 787 | } | |
| 788 | ||
| 789 | /** | |
| 790 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 791 | * caret change events and text change events. Text change events must | |
| 792 | * take priority over caret change events because it's possible to change | |
| 793 | * the text without moving the caret (e.g., delete selected text). | |
| 794 | * | |
| 795 | * @param file The file containing contents for the text editor. | |
| 796 | * @return A non-null text editor. | |
| 797 | */ | |
| 798 | private TextResource createMarkdownEditor( final File file ) { | |
| 799 | final var path = file.toPath(); | |
| 800 | final var editor = new MarkdownEditor( file, getWorkspace() ); | |
| 801 | final var caret = editor.getCaret(); | |
| 802 | final var context = createProcessorContext( path, caret ); | |
| 803 | ||
| 804 | mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | |
| 805 | ||
| 806 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 807 | if( n ) { | |
| 808 | // Reset the status to OK after changing the text. | |
| 809 | clue(); | |
| 810 | ||
| 811 | // Processing the text will update the status bar. | |
| 812 | process( getActiveTextEditor() ); | |
| 813 | } | |
| 814 | } ); | |
| 815 | ||
| 816 | editor.addEventListener( | |
| 817 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 818 | ); | |
| 819 | ||
| 820 | // Set the active editor, which refreshes the preview panel. | |
| 821 | mActiveTextEditor.set( editor ); | |
| 822 | ||
| 823 | return editor; | |
| 824 | } | |
| 825 | ||
| 826 | /** | |
| 827 | * Delegates to {@link #autoinsert()}. | |
| 828 | * | |
| 829 | * @param event Ignored. | |
| 830 | */ | |
| 831 | private void autoinsert( final KeyEvent event ) { | |
| 832 | autoinsert(); | |
| 833 | } | |
| 834 | ||
| 835 | /** | |
| 836 | * Finds a node that matches the word at the caret, then inserts the | |
| 837 | * corresponding definition. The definition token delimiters depend on | |
| 838 | * the type of file being edited. | |
| 839 | */ | |
| 840 | public void autoinsert() { | |
| 841 | final var definitions = getActiveTextDefinition(); | |
| 842 | final var editor = getActiveTextEditor(); | |
| 843 | final var mediaType = editor.getMediaType(); | |
| 844 | final var operator = getSigilOperator( mediaType ); | |
| 845 | ||
| 846 | DefinitionNameInjector.autoinsert( editor, definitions, operator ); | |
| 847 | } | |
| 848 | ||
| 849 | private TextDefinition createDefinitionEditor() { | |
| 850 | return createDefinitionEditor( DEFINITION_DEFAULT ); | |
| 851 | } | |
| 852 | ||
| 853 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 854 | final var editor = new DefinitionEditor( file, createTreeTransformer() ); | |
| 855 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 852 | 856 | return editor; |
| 853 | 857 | } |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.preferences.Workspace; |
| 5 | 5 | import com.keenwrite.ui.actions.ApplicationActions; |
| 6 | import com.keenwrite.ui.actions.ApplicationMenuBar; | |
| 7 | 6 | import com.keenwrite.ui.listeners.CaretListener; |
| 8 | 7 | import javafx.scene.Node; |
| 9 | 8 | import javafx.scene.Parent; |
| 10 | 9 | import javafx.scene.Scene; |
| 11 | 10 | import javafx.scene.layout.BorderPane; |
| 11 | import javafx.scene.layout.VBox; | |
| 12 | 12 | import org.controlsfx.control.StatusBar; |
| 13 | 13 | |
| 14 | 14 | import static com.keenwrite.Constants.STYLESHEET_SCENE; |
| 15 | import static com.keenwrite.StatusNotifier.getStatusBar; | |
| 16 | import static com.keenwrite.ui.actions.ApplicationBars.createMenuBar; | |
| 17 | import static com.keenwrite.ui.actions.ApplicationBars.createToolBar; | |
| 15 | 18 | |
| 16 | 19 | /** |
| 17 | 20 | * Responsible for creating the bar scene: menu bar, tool bar, and status bar. |
| 18 | 21 | */ |
| 19 | 22 | public class MainScene { |
| 20 | 23 | private final Scene mScene; |
| 24 | private final Node mMenuBar; | |
| 25 | private final Node mToolBar; | |
| 26 | private final StatusBar mStatusBar; | |
| 21 | 27 | |
| 22 | 28 | public MainScene( final Workspace workspace ) { |
| 23 | 29 | final var mainPane = createMainPane( workspace ); |
| 24 | 30 | final var actions = createApplicationActions( mainPane ); |
| 25 | final var menuBar = createMenuBar( actions ); | |
| 26 | final var appPane = new BorderPane(); | |
| 27 | final var statusBar = StatusBarNotifier.getStatusBar(); | |
| 28 | 31 | final var caretListener = createCaretListener( mainPane ); |
| 32 | mMenuBar = setManagedLayout( createMenuBar( actions ) ); | |
| 33 | mToolBar = setManagedLayout( createToolBar() ); | |
| 34 | mStatusBar = setManagedLayout( getStatusBar() ); | |
| 29 | 35 | |
| 30 | statusBar.getRightItems().add( caretListener ); | |
| 36 | mStatusBar.getRightItems().add( caretListener ); | |
| 31 | 37 | |
| 32 | appPane.setTop( menuBar ); | |
| 38 | final var appPane = new BorderPane(); | |
| 39 | appPane.setTop( new VBox( mMenuBar, mToolBar ) ); | |
| 33 | 40 | appPane.setCenter( mainPane ); |
| 34 | appPane.setBottom( statusBar ); | |
| 41 | appPane.setBottom( mStatusBar ); | |
| 35 | 42 | |
| 36 | 43 | mScene = createScene( appPane ); |
| ... | ||
| 45 | 52 | public Scene getScene() { |
| 46 | 53 | return mScene; |
| 54 | } | |
| 55 | ||
| 56 | public void toggleMenuBar() { | |
| 57 | final var node = mMenuBar; | |
| 58 | node.setVisible( !node.isVisible() ); | |
| 59 | } | |
| 60 | ||
| 61 | public void toggleToolBar() { | |
| 62 | final var node = mToolBar; | |
| 63 | node.setVisible( !node.isVisible() ); | |
| 64 | } | |
| 65 | ||
| 66 | public void toggleStatusBar() { | |
| 67 | final var node = mStatusBar; | |
| 68 | node.setVisible( !node.isVisible() ); | |
| 47 | 69 | } |
| 48 | 70 | |
| 49 | 71 | private MainPane createMainPane( final Workspace workspace ) { |
| 50 | 72 | return new MainPane( workspace ); |
| 51 | 73 | } |
| 52 | 74 | |
| 53 | 75 | private ApplicationActions createApplicationActions( |
| 54 | 76 | final MainPane mainPane ) { |
| 55 | return new ApplicationActions( mainPane ); | |
| 56 | } | |
| 57 | ||
| 58 | private Node createMenuBar( final ApplicationActions actions ) { | |
| 59 | return (new ApplicationMenuBar()).createMenuBar( actions ); | |
| 77 | return new ApplicationActions( this, mainPane ); | |
| 60 | 78 | } |
| 61 | 79 | |
| ... | ||
| 77 | 95 | private CaretListener createCaretListener( final MainPane mainPane ) { |
| 78 | 96 | return new CaretListener( mainPane.activeTextEditorProperty() ); |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * Binds the visible property of the node to the managed property so that | |
| 101 | * hiding the node also removes the screen real estate that it occupies. | |
| 102 | * | |
| 103 | * @param node The node to have its real estate bound to visibility. | |
| 104 | * @return The given node. | |
| 105 | */ | |
| 106 | private <T extends Node> T setManagedLayout( final T node ) { | |
| 107 | node.managedProperty().bind( node.visibleProperty() ); | |
| 108 | return node; | |
| 79 | 109 | } |
| 80 | 110 | } |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.service.events.Notifier; | |
| 5 | import org.controlsfx.control.StatusBar; | |
| 6 | ||
| 7 | import static com.keenwrite.Constants.STATUS_BAR_OK; | |
| 8 | import static com.keenwrite.Messages.get; | |
| 9 | import static javafx.application.Platform.runLater; | |
| 10 | ||
| 11 | /** | |
| 12 | * Responsible for passing notifications about exceptions (or other error | |
| 13 | * messages) through the application. Once the Event Bus is implemented, this | |
| 14 | * class can go away. | |
| 15 | */ | |
| 16 | public class StatusBarNotifier { | |
| 17 | private static final String OK = get( STATUS_BAR_OK, "OK" ); | |
| 18 | ||
| 19 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 20 | private static final StatusBar sStatusBar = new StatusBar(); | |
| 21 | ||
| 22 | /** | |
| 23 | * Resets the status bar to a default message. | |
| 24 | */ | |
| 25 | public static void clue() { | |
| 26 | // Don't burden the repaint thread if there's no status bar change. | |
| 27 | if( !OK.equals( sStatusBar.getText() ) ) { | |
| 28 | update( OK ); | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | /** | |
| 33 | * Updates the status bar with a custom message. | |
| 34 | * | |
| 35 | * @param key The resource bundle key associated with a message (typically | |
| 36 | * to inform the user about an error). | |
| 37 | */ | |
| 38 | public static void clue( final String key ) { | |
| 39 | update( get( key ) ); | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Updates the status bar with a custom message. | |
| 44 | * | |
| 45 | * @param key The property key having a value to populate with arguments. | |
| 46 | * @param args The placeholder values to substitute into the key's value. | |
| 47 | */ | |
| 48 | public static void clue( final String key, final Object... args ) { | |
| 49 | update( get( key, args ) ); | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Called when an exception occurs that warrants the user's attention. | |
| 54 | * | |
| 55 | * @param t The exception with a message that the user should know about. | |
| 56 | */ | |
| 57 | public static void clue( final Throwable t ) { | |
| 58 | update( t.getMessage() ); | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Returns the global {@link Notifier} instance that can be used for opening | |
| 63 | * pop-up alert messages. | |
| 64 | * | |
| 65 | * @return The pop-up {@link Notifier} dispatcher. | |
| 66 | */ | |
| 67 | public static Notifier getNotifier() { | |
| 68 | return sNotifier; | |
| 69 | } | |
| 70 | ||
| 71 | public static StatusBar getStatusBar() { | |
| 72 | return sStatusBar; | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * Updates the status bar to show the first line of the given message. | |
| 77 | * | |
| 78 | * @param message The message to show in the status bar. | |
| 79 | */ | |
| 80 | private static void update( final String message ) { | |
| 81 | runLater( | |
| 82 | () -> { | |
| 83 | final var s = message == null ? "" : message; | |
| 84 | final var i = s.indexOf( '\n' ); | |
| 85 | sStatusBar.setText( s.substring( 0, i > 0 ? i : s.length() ) ); | |
| 86 | } | |
| 87 | ); | |
| 88 | } | |
| 89 | } | |
| 90 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.service.events.Notifier; | |
| 5 | import com.keenwrite.ui.logging.LogView; | |
| 6 | import org.controlsfx.control.StatusBar; | |
| 7 | ||
| 8 | import static com.keenwrite.Constants.STATUS_BAR_OK; | |
| 9 | import static com.keenwrite.Messages.get; | |
| 10 | import static javafx.application.Platform.runLater; | |
| 11 | ||
| 12 | /** | |
| 13 | * Responsible for passing notifications about exceptions (or other error | |
| 14 | * messages) through the application. Once the Event Bus is implemented, this | |
| 15 | * class can go away. | |
| 16 | */ | |
| 17 | public class StatusNotifier { | |
| 18 | private static final String OK = get( STATUS_BAR_OK, "OK" ); | |
| 19 | ||
| 20 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 21 | private static final StatusBar sStatusBar = new StatusBar(); | |
| 22 | private static final LogView sLogView = new LogView(); | |
| 23 | ||
| 24 | /** | |
| 25 | * Resets the status bar to a default message. | |
| 26 | */ | |
| 27 | public static void clue() { | |
| 28 | // Don't burden the repaint thread if there's no status bar change. | |
| 29 | if( !OK.equals( sStatusBar.getText() ) ) { | |
| 30 | update( OK ); | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Updates the status bar with a custom message. | |
| 36 | * | |
| 37 | * @param key The property key having a value to populate with arguments. | |
| 38 | * @param args The placeholder values to substitute into the key's value. | |
| 39 | */ | |
| 40 | public static void clue( final String key, final Object... args ) { | |
| 41 | final var message = get( key, args ); | |
| 42 | update( message ); | |
| 43 | sLogView.log( message ); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Update the status bar with a pre-parsed message and exception. | |
| 48 | * | |
| 49 | * @param message The custom message to log. | |
| 50 | * @param t The exception that triggered the status update. | |
| 51 | */ | |
| 52 | public static void clue( final String message, final Throwable t ) { | |
| 53 | update( message ); | |
| 54 | sLogView.log( message, t ); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Called when an exception occurs that warrants the user's attention. | |
| 59 | * | |
| 60 | * @param t The exception with a message that the user should know about. | |
| 61 | */ | |
| 62 | public static void clue( final Throwable t ) { | |
| 63 | update( t.getMessage() ); | |
| 64 | sLogView.log( t ); | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Returns the global {@link Notifier} instance that can be used for opening | |
| 69 | * pop-up alert messages. | |
| 70 | * | |
| 71 | * @return The pop-up {@link Notifier} dispatcher. | |
| 72 | */ | |
| 73 | public static Notifier getNotifier() { | |
| 74 | return sNotifier; | |
| 75 | } | |
| 76 | ||
| 77 | public static StatusBar getStatusBar() { | |
| 78 | return sStatusBar; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Updates the status bar to show the first line of the given message. | |
| 83 | * | |
| 84 | * @param message The message to show in the status bar. | |
| 85 | */ | |
| 86 | private static void update( final String message ) { | |
| 87 | runLater( | |
| 88 | () -> { | |
| 89 | final var s = message == null ? "" : message; | |
| 90 | final var i = s.indexOf( '\n' ); | |
| 91 | sStatusBar.setText( s.substring( 0, i > 0 ? i : s.length() ) ); | |
| 92 | } | |
| 93 | ); | |
| 94 | } | |
| 95 | ||
| 96 | public static void viewIssues() { | |
| 97 | sLogView.view(); | |
| 98 | } | |
| 99 | } | |
| 1 | 100 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors; |
| 3 | 3 | |
| 4 | import com.keenwrite.processors.markdown.Caret; | |
| 4 | import com.keenwrite.Caret; | |
| 5 | 5 | import javafx.scene.control.IndexRange; |
| 6 | 6 | import org.fxmisc.flowless.VirtualizedScrollPane; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors; |
| 3 | 3 | |
| ... | ||
| 12 | 12 | |
| 13 | 13 | import static com.keenwrite.Constants.DEFAULT_CHARSET; |
| 14 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 14 | import static com.keenwrite.StatusNotifier.clue; | |
| 15 | 15 | import static java.nio.charset.Charset.forName; |
| 16 | 16 | import static java.nio.file.Files.readAllBytes; |
| 17 | 17 | import static java.nio.file.Files.write; |
| 18 | import static java.util.Arrays.asList; | |
| 18 | 19 | import static java.util.Locale.ENGLISH; |
| 19 | 20 | |
| ... | ||
| 80 | 81 | default MediaType getMediaType() { |
| 81 | 82 | return MediaType.valueFrom( getFile() ); |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * Answers whether this instance is an editor for at least one of the given | |
| 87 | * {@link MediaType} references. | |
| 88 | * | |
| 89 | * @param mediaTypes The {@link MediaType} references to compare against. | |
| 90 | * @return {@code true} if the given list of media types contains the | |
| 91 | * {@link MediaType} for this editor. | |
| 92 | */ | |
| 93 | default boolean isMediaType( final MediaType... mediaTypes ) { | |
| 94 | return asList( mediaTypes ).contains( getMediaType() ); | |
| 82 | 95 | } |
| 83 | 96 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 | |
| ... | ||
| 27 | 27 | import static com.keenwrite.Constants.DEFINITION_DEFAULT; |
| 28 | 28 | import static com.keenwrite.Messages.get; |
| 29 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 29 | import static com.keenwrite.StatusNotifier.clue; | |
| 30 | 30 | import static java.lang.String.format; |
| 31 | 31 | import static java.util.regex.Pattern.compile; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. | |
| 2 | 2 | * |
| 3 | 3 | * All rights reserved. |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition.yaml; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.editors.definition.yaml; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. | |
| 2 | 2 | * |
| 3 | 3 | * All rights reserved. |
| 1 | /* | |
| 2 | * Copyright 2020 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 28 | 2 | package com.keenwrite.editors.markdown; |
| 29 | 3 | |
| ... | ||
| 53 | 27 | * Constructs a new hyperlink model for the given AST link. |
| 54 | 28 | * |
| 55 | * @param link A markdown link. | |
| 29 | * @param link A Markdown link. | |
| 56 | 30 | */ |
| 57 | 31 | public HyperlinkModel( final Link link ) { |
| 58 | 32 | this( |
| 59 | link.getText().toString(), | |
| 60 | link.getUrl().toString(), | |
| 61 | link.getTitle().toString() | |
| 33 | link.getText().toString(), | |
| 34 | link.getUrl().toString(), | |
| 35 | link.getTitle().toString() | |
| 62 | 36 | ); |
| 63 | 37 | } |
| ... | ||
| 70 | 44 | * @param title The hyperlink title (e.g., shown as a tooltip). |
| 71 | 45 | */ |
| 72 | public HyperlinkModel( final String text, final String url, | |
| 73 | final String title ) { | |
| 46 | public HyperlinkModel( | |
| 47 | final String text, final String url, final String title ) { | |
| 74 | 48 | setText( text ); |
| 75 | 49 | setUrl( url ); |
| 76 | 50 | setTitle( title ); |
| 77 | 51 | } |
| 78 | 52 | |
| 79 | 53 | /** |
| 80 | 54 | * Returns the string in Markdown format by default. |
| 81 | 55 | * |
| 82 | * @return A markdown version of the hyperlink. | |
| 56 | * @return A Markdown version of the hyperlink. | |
| 83 | 57 | */ |
| 84 | 58 | @Override |
| ... | ||
| 97 | 71 | |
| 98 | 72 | public final void setText( final String text ) { |
| 99 | this.text = nullSafe( text ); | |
| 73 | this.text = sanitize( text ); | |
| 100 | 74 | } |
| 101 | 75 | |
| 102 | 76 | public final void setUrl( final String url ) { |
| 103 | this.url = nullSafe( url ); | |
| 77 | this.url = sanitize( url ); | |
| 104 | 78 | } |
| 105 | 79 | |
| 106 | 80 | public final void setTitle( final String title ) { |
| 107 | this.title = nullSafe( title ); | |
| 81 | this.title = sanitize( title ); | |
| 108 | 82 | } |
| 109 | 83 | |
| ... | ||
| 138 | 112 | } |
| 139 | 113 | |
| 140 | private String nullSafe( final String s ) { | |
| 114 | private String sanitize( final String s ) { | |
| 141 | 115 | return s == null ? "" : s; |
| 142 | 116 | } |
| 1 | 1 | /* |
| 2 | * Copyright 2020 White Magic Software, Ltd. | |
| 2 | * Copyright 2020-2021 White Magic Software, Ltd. | |
| 3 | 3 | * |
| 4 | 4 | * All rights reserved. |
| ... | ||
| 39 | 39 | public class LinkVisitor { |
| 40 | 40 | |
| 41 | private NodeVisitor visitor; | |
| 42 | private Link link; | |
| 43 | private final int offset; | |
| 41 | private NodeVisitor mVisitor; | |
| 42 | private Link mLink; | |
| 43 | private final int mOffset; | |
| 44 | 44 | |
| 45 | 45 | /** |
| 46 | * Creates a hyperlink given an offset into a paragraph and the markdown AST | |
| 46 | * Creates a hyperlink given an offset into a paragraph and the Markdown AST | |
| 47 | 47 | * link node. |
| 48 | 48 | * |
| 49 | 49 | * @param index Index into the paragraph that indicates the hyperlink to |
| 50 | 50 | * change. |
| 51 | 51 | */ |
| 52 | 52 | public LinkVisitor( final int index ) { |
| 53 | this.offset = index; | |
| 53 | mOffset = index; | |
| 54 | 54 | } |
| 55 | 55 | |
| ... | ||
| 73 | 73 | |
| 74 | 74 | private synchronized NodeVisitor getVisitor() { |
| 75 | if( this.visitor == null ) { | |
| 76 | this.visitor = createVisitor(); | |
| 75 | if( mVisitor == null ) { | |
| 76 | mVisitor = createVisitor(); | |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | return this.visitor; | |
| 79 | return mVisitor; | |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | protected NodeVisitor createVisitor() { |
| 83 | 83 | return new NodeVisitor( |
| 84 | new VisitHandler<>( Link.class, LinkVisitor.this::visit ) ); | |
| 84 | new VisitHandler<>( Link.class, LinkVisitor.this::visit ) ); | |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | 87 | private Link getLink() { |
| 88 | return this.link; | |
| 88 | return mLink; | |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | 91 | private void setLink( final Link link ) { |
| 92 | this.link = link; | |
| 92 | mLink = link; | |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | 95 | public int getOffset() { |
| 96 | return this.offset; | |
| 96 | return mOffset; | |
| 97 | 97 | } |
| 98 | 98 | } |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.editors.markdown; | |
| 3 | ||
| 4 | import com.keenwrite.Constants; | |
| 5 | import com.keenwrite.editors.TextEditor; | |
| 6 | import com.keenwrite.preferences.LocaleProperty; | |
| 7 | import com.keenwrite.preferences.Workspace; | |
| 8 | import com.keenwrite.processors.markdown.Caret; | |
| 9 | import com.keenwrite.spelling.impl.TextEditorSpeller; | |
| 10 | import javafx.beans.binding.Bindings; | |
| 11 | import javafx.beans.property.BooleanProperty; | |
| 12 | import javafx.beans.property.DoubleProperty; | |
| 13 | import javafx.beans.property.ReadOnlyBooleanProperty; | |
| 14 | import javafx.beans.property.SimpleBooleanProperty; | |
| 15 | import javafx.beans.value.ChangeListener; | |
| 16 | import javafx.event.Event; | |
| 17 | import javafx.scene.Node; | |
| 18 | import javafx.scene.control.IndexRange; | |
| 19 | import javafx.scene.input.KeyCode; | |
| 20 | import javafx.scene.input.KeyEvent; | |
| 21 | import javafx.scene.layout.BorderPane; | |
| 22 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 23 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 24 | import org.fxmisc.richtext.model.StyleSpans; | |
| 25 | import org.fxmisc.undo.UndoManager; | |
| 26 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 27 | import org.fxmisc.wellbehaved.event.Nodes; | |
| 28 | ||
| 29 | import java.io.File; | |
| 30 | import java.nio.charset.Charset; | |
| 31 | import java.text.BreakIterator; | |
| 32 | import java.util.*; | |
| 33 | import java.util.function.Consumer; | |
| 34 | import java.util.function.Supplier; | |
| 35 | import java.util.regex.Pattern; | |
| 36 | ||
| 37 | import static com.keenwrite.Constants.*; | |
| 38 | import static com.keenwrite.Messages.get; | |
| 39 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 40 | import static com.keenwrite.preferences.Workspace.KEY_UI_FONT_EDITOR_SIZE; | |
| 41 | import static com.keenwrite.preferences.Workspace.KEY_UI_FONT_LOCALE; | |
| 42 | import static java.lang.Character.isWhitespace; | |
| 43 | import static java.lang.Math.max; | |
| 44 | import static java.lang.String.format; | |
| 45 | import static java.util.Collections.singletonList; | |
| 46 | import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | |
| 47 | import static javafx.scene.input.KeyCode.*; | |
| 48 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 49 | import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | |
| 50 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 51 | import static org.apache.commons.lang3.StringUtils.stripEnd; | |
| 52 | import static org.apache.commons.lang3.StringUtils.stripStart; | |
| 53 | import static org.fxmisc.richtext.model.StyleSpans.singleton; | |
| 54 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 55 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 56 | ||
| 57 | /** | |
| 58 | * Responsible for editing Markdown documents. | |
| 59 | */ | |
| 60 | public class MarkdownEditor extends BorderPane implements TextEditor { | |
| 61 | private static final String NEWLINE = System.lineSeparator(); | |
| 62 | ||
| 63 | /** | |
| 64 | * Regular expression that matches the type of markup block. This is used | |
| 65 | * when Enter is pressed to continue the block environment. | |
| 66 | */ | |
| 67 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | |
| 68 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 69 | ||
| 70 | /** | |
| 71 | * The text editor. | |
| 72 | */ | |
| 73 | private final StyleClassedTextArea mTextArea = | |
| 74 | new StyleClassedTextArea( false ); | |
| 75 | ||
| 76 | /** | |
| 77 | * Wraps the text editor in scrollbars. | |
| 78 | */ | |
| 79 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 80 | new VirtualizedScrollPane<>( mTextArea ); | |
| 81 | ||
| 82 | private final Workspace mWorkspace; | |
| 83 | ||
| 84 | /** | |
| 85 | * Tracks where the caret is located in this document. This offers observable | |
| 86 | * properties for caret position changes. | |
| 87 | */ | |
| 88 | private final Caret mCaret = createCaret( mTextArea ); | |
| 89 | ||
| 90 | /** | |
| 91 | * File being edited by this editor instance. | |
| 92 | */ | |
| 93 | private File mFile; | |
| 94 | ||
| 95 | /** | |
| 96 | * Set to {@code true} upon text or caret position changes. Value is {@code | |
| 97 | * false} by default. | |
| 98 | */ | |
| 99 | private final BooleanProperty mDirty = new SimpleBooleanProperty(); | |
| 100 | ||
| 101 | /** | |
| 102 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 103 | * either no encoding could be determined or this is a new (empty) file. | |
| 104 | */ | |
| 105 | private final Charset mEncoding; | |
| 106 | ||
| 107 | /** | |
| 108 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 109 | * persisted definitions. | |
| 110 | */ | |
| 111 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 112 | ||
| 113 | public MarkdownEditor( final Workspace workspace ) { | |
| 114 | this( DOCUMENT_DEFAULT, workspace ); | |
| 115 | } | |
| 116 | ||
| 117 | public MarkdownEditor( final File file, final Workspace workspace ) { | |
| 118 | mEncoding = open( mFile = file ); | |
| 119 | mWorkspace = workspace; | |
| 120 | ||
| 121 | initTextArea( mTextArea ); | |
| 122 | initStyle( mTextArea ); | |
| 123 | initScrollPane( mScrollPane ); | |
| 124 | initSpellchecker( mTextArea ); | |
| 125 | initHotKeys(); | |
| 126 | initUndoManager(); | |
| 127 | } | |
| 128 | ||
| 129 | private void initTextArea( final StyleClassedTextArea textArea ) { | |
| 130 | textArea.setWrapText( true ); | |
| 131 | textArea.requestFollowCaret(); | |
| 132 | textArea.moveTo( 0 ); | |
| 133 | ||
| 134 | textArea.textProperty().addListener( ( c, o, n ) -> { | |
| 135 | // Fire, regardless of whether the caret position has changed. | |
| 136 | mDirty.set( false ); | |
| 137 | ||
| 138 | // Prevent a caret position change from raising the dirty bits. | |
| 139 | mDirty.set( true ); | |
| 140 | } ); | |
| 141 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | |
| 142 | // Fire when the caret position has changed and the text has not. | |
| 143 | mDirty.set( true ); | |
| 144 | mDirty.set( false ); | |
| 145 | } ); | |
| 146 | } | |
| 147 | ||
| 148 | private void initStyle( final StyleClassedTextArea textArea ) { | |
| 149 | textArea.getStyleClass().add( "markdown" ); | |
| 150 | ||
| 151 | final var stylesheets = textArea.getStylesheets(); | |
| 152 | stylesheets.add( STYLESHEET_MARKDOWN ); | |
| 153 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 154 | ||
| 155 | localeProperty().addListener( ( c, o, n ) -> { | |
| 156 | if( n != null ) { | |
| 157 | stylesheets.remove( max( 0, stylesheets.size() - 1 ) ); | |
| 158 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 159 | } | |
| 160 | } ); | |
| 161 | ||
| 162 | fontSizeProperty().addListener( ( c, o, n ) -> { | |
| 163 | mTextArea.setStyle( format( "-fx-font-size: %spt;", getFontSize() ) ); | |
| 164 | } ); | |
| 165 | } | |
| 166 | ||
| 167 | private void initScrollPane( | |
| 168 | final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | |
| 169 | scrollpane.setVbarPolicy( ALWAYS ); | |
| 170 | setCenter( scrollpane ); | |
| 171 | } | |
| 172 | ||
| 173 | private void initSpellchecker( final StyleClassedTextArea textarea ) { | |
| 174 | final var speller = new TextEditorSpeller(); | |
| 175 | speller.checkDocument( textarea ); | |
| 176 | speller.checkParagraphs( textarea ); | |
| 177 | } | |
| 178 | ||
| 179 | private void initHotKeys() { | |
| 180 | addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | |
| 181 | addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | |
| 182 | addEventListener( keyPressed( TAB ), this::tab ); | |
| 183 | addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | |
| 184 | } | |
| 185 | ||
| 186 | private void initUndoManager() { | |
| 187 | final var undoManager = getUndoManager(); | |
| 188 | final var markedPosition = undoManager.atMarkedPositionProperty(); | |
| 189 | ||
| 190 | undoManager.forgetHistory(); | |
| 191 | undoManager.mark(); | |
| 192 | mModified.bind( Bindings.not( markedPosition ) ); | |
| 193 | } | |
| 194 | ||
| 195 | @Override | |
| 196 | public void moveTo( final int offset ) { | |
| 197 | assert 0 <= offset && offset <= mTextArea.getLength(); | |
| 198 | mTextArea.moveTo( offset ); | |
| 199 | mTextArea.requestFollowCaret(); | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * Delegate the focus request to the text area itself. | |
| 204 | */ | |
| 205 | @Override | |
| 206 | public void requestFocus() { | |
| 207 | mTextArea.requestFocus(); | |
| 208 | } | |
| 209 | ||
| 210 | @Override | |
| 211 | public void setText( final String text ) { | |
| 212 | mTextArea.clear(); | |
| 213 | mTextArea.appendText( text ); | |
| 214 | mTextArea.getUndoManager().mark(); | |
| 215 | } | |
| 216 | ||
| 217 | @Override | |
| 218 | public String getText() { | |
| 219 | return mTextArea.getText(); | |
| 220 | } | |
| 221 | ||
| 222 | @Override | |
| 223 | public Charset getEncoding() { | |
| 224 | return mEncoding; | |
| 225 | } | |
| 226 | ||
| 227 | @Override | |
| 228 | public File getFile() { | |
| 229 | return mFile; | |
| 230 | } | |
| 231 | ||
| 232 | @Override | |
| 233 | public void rename( final File file ) { | |
| 234 | mFile = file; | |
| 235 | } | |
| 236 | ||
| 237 | @Override | |
| 238 | public void undo() { | |
| 239 | final var manager = getUndoManager(); | |
| 240 | xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | |
| 241 | } | |
| 242 | ||
| 243 | @Override | |
| 244 | public void redo() { | |
| 245 | final var manager = getUndoManager(); | |
| 246 | xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * Performs an undo or redo action, if possible, otherwise displays an error | |
| 251 | * message to the user. | |
| 252 | * | |
| 253 | * @param ready Answers whether the action can be executed. | |
| 254 | * @param action The action to execute. | |
| 255 | * @param key The informational message key having a value to display if | |
| 256 | * the {@link Supplier} is not ready. | |
| 257 | */ | |
| 258 | private void xxdo( | |
| 259 | final Supplier<Boolean> ready, final Runnable action, final String key ) { | |
| 260 | if( ready.get() ) { | |
| 261 | action.run(); | |
| 262 | } | |
| 263 | else { | |
| 264 | clue( key ); | |
| 265 | } | |
| 266 | } | |
| 267 | ||
| 268 | @Override | |
| 269 | public void cut() { | |
| 270 | final var selected = mTextArea.getSelectedText(); | |
| 271 | ||
| 272 | // Emulate selecting the current line by firing Home then Shift+Down Arrow. | |
| 273 | if( selected == null || selected.isEmpty() ) { | |
| 274 | // Note: mTextArea.selectLine() does not select empty lines. | |
| 275 | mTextArea.fireEvent( keyEvent( HOME, false ) ); | |
| 276 | mTextArea.fireEvent( keyEvent( DOWN, true ) ); | |
| 277 | } | |
| 278 | ||
| 279 | mTextArea.cut(); | |
| 280 | } | |
| 281 | ||
| 282 | private Event keyEvent( final KeyCode code, final boolean shift ) { | |
| 283 | return new KeyEvent( | |
| 284 | KEY_PRESSED, "", "", code, shift, false, false, false | |
| 285 | ); | |
| 286 | } | |
| 287 | ||
| 288 | @Override | |
| 289 | public void copy() { | |
| 290 | mTextArea.copy(); | |
| 291 | } | |
| 292 | ||
| 293 | @Override | |
| 294 | public void paste() { | |
| 295 | mTextArea.paste(); | |
| 296 | } | |
| 297 | ||
| 298 | @Override | |
| 299 | public void selectAll() { | |
| 300 | mTextArea.selectAll(); | |
| 301 | } | |
| 302 | ||
| 303 | @Override | |
| 304 | public void bold() { | |
| 305 | enwrap( "**" ); | |
| 306 | } | |
| 307 | ||
| 308 | @Override | |
| 309 | public void italic() { | |
| 310 | enwrap( "*" ); | |
| 311 | } | |
| 312 | ||
| 313 | @Override | |
| 314 | public void superscript() { | |
| 315 | enwrap( "^" ); | |
| 316 | } | |
| 317 | ||
| 318 | @Override | |
| 319 | public void subscript() { | |
| 320 | enwrap( "~" ); | |
| 321 | } | |
| 322 | ||
| 323 | @Override | |
| 324 | public void strikethrough() { | |
| 325 | enwrap( "~~" ); | |
| 326 | } | |
| 327 | ||
| 328 | @Override | |
| 329 | public void blockquote() { | |
| 330 | block( "> " ); | |
| 331 | } | |
| 332 | ||
| 333 | @Override | |
| 334 | public void code() { | |
| 335 | enwrap( "`" ); | |
| 336 | } | |
| 337 | ||
| 338 | @Override | |
| 339 | public void fencedCodeBlock() { | |
| 340 | final var key = "App.action.insert.fenced_code_block.prompt.text"; | |
| 341 | ||
| 342 | // TODO: Introduce sample text if nothing is selected. | |
| 343 | //enwrap( "\n\n```\n", "\n```\n\n", get( key ) ); | |
| 344 | } | |
| 345 | ||
| 346 | @Override | |
| 347 | public void heading( final int level ) { | |
| 348 | final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | |
| 349 | block( format( "%s ", hashes ) ); | |
| 350 | } | |
| 351 | ||
| 352 | @Override | |
| 353 | public void unorderedList() { | |
| 354 | block( "* " ); | |
| 355 | } | |
| 356 | ||
| 357 | @Override | |
| 358 | public void orderedList() { | |
| 359 | block( "1. " ); | |
| 360 | } | |
| 361 | ||
| 362 | @Override | |
| 363 | public void horizontalRule() { | |
| 364 | block( format( "---%n%n" ) ); | |
| 365 | } | |
| 366 | ||
| 367 | @Override | |
| 368 | public Node getNode() { | |
| 369 | return this; | |
| 370 | } | |
| 371 | ||
| 372 | @Override | |
| 373 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 374 | return mModified; | |
| 375 | } | |
| 376 | ||
| 377 | @Override | |
| 378 | public void clearModifiedProperty() { | |
| 379 | getUndoManager().mark(); | |
| 380 | } | |
| 381 | ||
| 382 | @Override | |
| 383 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 384 | return mScrollPane; | |
| 385 | } | |
| 386 | ||
| 387 | @Override | |
| 388 | public StyleClassedTextArea getTextArea() { | |
| 389 | return mTextArea; | |
| 390 | } | |
| 391 | ||
| 392 | private final Map<String, IndexRange> mStyles = new HashMap<>(); | |
| 393 | ||
| 394 | @Override | |
| 395 | public void stylize( final IndexRange range, final String style ) { | |
| 396 | final var began = range.getStart(); | |
| 397 | final var ended = range.getEnd() + 1; | |
| 398 | ||
| 399 | assert 0 <= began && began <= ended; | |
| 400 | assert style != null; | |
| 401 | ||
| 402 | // TODO: Ensure spell check and find highlights can coexist. | |
| 403 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 404 | // System.out.println( "SPANS: " + spans ); | |
| 405 | ||
| 406 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 407 | // mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | |
| 408 | // ) ); | |
| 409 | ||
| 410 | // final var builder = new StyleSpansBuilder<Collection<String>>(); | |
| 411 | // builder.add( singleton( style ), range.getLength() + 1 ); | |
| 412 | // mTextArea.setStyleSpans( began, builder.create() ); | |
| 413 | ||
| 414 | // final var s = mTextArea.getStyleSpans( began, ended ); | |
| 415 | // System.out.println( "STYLES: " +s ); | |
| 416 | ||
| 417 | mStyles.put( style, range ); | |
| 418 | mTextArea.setStyleClass( began, ended, style ); | |
| 419 | ||
| 420 | // Ensure that whenever the user interacts with the text that the found | |
| 421 | // word will have its highlighting removed. The handler removes itself. | |
| 422 | // This won't remove the highlighting if the caret position moves by mouse. | |
| 423 | final var handler = mTextArea.getOnKeyPressed(); | |
| 424 | mTextArea.setOnKeyPressed( ( event ) -> { | |
| 425 | mTextArea.setOnKeyPressed( handler ); | |
| 426 | unstylize( style ); | |
| 427 | } ); | |
| 428 | ||
| 429 | //mTextArea.setStyleSpans(began, ended, s); | |
| 430 | } | |
| 431 | ||
| 432 | private static StyleSpans<Collection<String>> merge( | |
| 433 | StyleSpans<Collection<String>> spans, int len, String style ) { | |
| 434 | spans = spans.overlay( | |
| 435 | singleton( singletonList( style ), len ), | |
| 436 | ( bottomSpan, list ) -> { | |
| 437 | final List<String> l = | |
| 438 | new ArrayList<>( bottomSpan.size() + list.size() ); | |
| 439 | l.addAll( bottomSpan ); | |
| 440 | l.addAll( list ); | |
| 441 | return l; | |
| 442 | } ); | |
| 443 | ||
| 444 | return spans; | |
| 445 | } | |
| 446 | ||
| 447 | @Override | |
| 448 | public void unstylize( final String style ) { | |
| 449 | final var indexes = mStyles.remove( style ); | |
| 450 | if( indexes != null ) { | |
| 451 | mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | |
| 452 | } | |
| 453 | } | |
| 454 | ||
| 455 | @Override | |
| 456 | public Caret getCaret() { | |
| 457 | return mCaret; | |
| 458 | } | |
| 459 | ||
| 460 | private Caret createCaret( final StyleClassedTextArea editor ) { | |
| 461 | return Caret | |
| 462 | .builder() | |
| 463 | .with( Caret.Mutator::setEditor, editor ) | |
| 464 | .build(); | |
| 465 | } | |
| 466 | ||
| 467 | /** | |
| 468 | * This method adds listeners to editor events. | |
| 469 | * | |
| 470 | * @param <T> The event type. | |
| 471 | * @param <U> The consumer type for the given event type. | |
| 472 | * @param event The event of interest. | |
| 473 | * @param consumer The method to call when the event happens. | |
| 474 | */ | |
| 475 | public <T extends Event, U extends T> void addEventListener( | |
| 476 | final EventPattern<? super T, ? extends U> event, | |
| 477 | final Consumer<? super U> consumer ) { | |
| 478 | Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | |
| 479 | } | |
| 480 | ||
| 481 | @SuppressWarnings( "unused" ) | |
| 482 | private void onEnterPressed( final KeyEvent event ) { | |
| 483 | final var currentLine = getCaretParagraph(); | |
| 484 | final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | |
| 485 | ||
| 486 | // By default, insert a new line by itself. | |
| 487 | String newText = NEWLINE; | |
| 488 | ||
| 489 | // If the pattern was matched then determine what block type to continue. | |
| 490 | if( matcher.matches() ) { | |
| 491 | if( matcher.group( 2 ).isEmpty() ) { | |
| 492 | final var pos = mTextArea.getCaretPosition(); | |
| 493 | mTextArea.selectRange( pos - currentLine.length(), pos ); | |
| 494 | } | |
| 495 | else { | |
| 496 | // Indent the new line with the same whitespace characters and | |
| 497 | // list markers as current line. This ensures that the indentation | |
| 498 | // is propagated. | |
| 499 | newText = newText.concat( matcher.group( 1 ) ); | |
| 500 | } | |
| 501 | } | |
| 502 | ||
| 503 | mTextArea.replaceSelection( newText ); | |
| 504 | } | |
| 505 | ||
| 506 | private void cut( final KeyEvent event ) { | |
| 507 | cut(); | |
| 508 | } | |
| 509 | ||
| 510 | private void tab( final KeyEvent event ) { | |
| 511 | final var range = mTextArea.selectionProperty().getValue(); | |
| 512 | final var sb = new StringBuilder( 1024 ); | |
| 513 | ||
| 514 | if( range.getLength() > 0 ) { | |
| 515 | final var selection = mTextArea.getSelectedText(); | |
| 516 | ||
| 517 | selection.lines().forEach( | |
| 518 | ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 519 | ); | |
| 520 | } | |
| 521 | else { | |
| 522 | sb.append( "\t" ); | |
| 523 | } | |
| 524 | ||
| 525 | mTextArea.replaceSelection( sb.toString() ); | |
| 526 | } | |
| 527 | ||
| 528 | private void untab( final KeyEvent event ) { | |
| 529 | final var range = mTextArea.selectionProperty().getValue(); | |
| 530 | ||
| 531 | if( range.getLength() > 0 ) { | |
| 532 | final var selection = mTextArea.getSelectedText(); | |
| 533 | final var sb = new StringBuilder( selection.length() ); | |
| 534 | ||
| 535 | selection.lines().forEach( | |
| 536 | ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | |
| 537 | .append( NEWLINE ) | |
| 538 | ); | |
| 539 | ||
| 540 | mTextArea.replaceSelection( sb.toString() ); | |
| 541 | } | |
| 542 | else { | |
| 543 | final var p = getCaretParagraph(); | |
| 544 | ||
| 545 | if( p.startsWith( "\t" ) ) { | |
| 546 | mTextArea.selectParagraph(); | |
| 547 | mTextArea.replaceSelection( p.substring( 1 ) ); | |
| 548 | } | |
| 549 | } | |
| 550 | } | |
| 551 | ||
| 552 | /** | |
| 553 | * Observers may listen for changes to the property returned from this method | |
| 554 | * to receive notifications when either the text or caret have changed. This | |
| 555 | * should not be used to track whether the text has been modified. | |
| 556 | */ | |
| 557 | public void addDirtyListener( ChangeListener<Boolean> listener ) { | |
| 558 | mDirty.addListener( listener ); | |
| 559 | } | |
| 560 | ||
| 561 | /** | |
| 562 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 563 | * | |
| 564 | * @param token The beginning and ending token for enclosing the text. | |
| 565 | */ | |
| 566 | private void enwrap( final String token ) { | |
| 567 | enwrap( token, token ); | |
| 568 | } | |
| 569 | ||
| 570 | /** | |
| 571 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 572 | * | |
| 573 | * @param began The beginning token for enclosing the text. | |
| 574 | * @param ended The ending token for enclosing the text. | |
| 575 | */ | |
| 576 | private void enwrap( final String began, String ended ) { | |
| 577 | // Ensure selected text takes precedence over the word at caret position. | |
| 578 | final var selected = mTextArea.selectionProperty().getValue(); | |
| 579 | final var range = selected.getLength() == 0 | |
| 580 | ? getCaretWord() | |
| 581 | : selected; | |
| 582 | String text = mTextArea.getText( range ); | |
| 583 | ||
| 584 | int length = range.getLength(); | |
| 585 | text = stripStart( text, null ); | |
| 586 | final int beganIndex = range.getStart() + (length - text.length()); | |
| 587 | ||
| 588 | length = text.length(); | |
| 589 | text = stripEnd( text, null ); | |
| 590 | final int endedIndex = range.getEnd() - (length - text.length()); | |
| 591 | ||
| 592 | mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | |
| 593 | } | |
| 594 | ||
| 595 | /** | |
| 596 | * Inserts the given block-level markup at the current caret position | |
| 597 | * within the document. This will prepend two blank lines to ensure that | |
| 598 | * the block element begins at the start of a new line. | |
| 599 | * | |
| 600 | * @param markup The text to insert at the caret. | |
| 601 | */ | |
| 602 | private void block( final String markup ) { | |
| 603 | final int pos = mTextArea.getCaretPosition(); | |
| 604 | mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | |
| 605 | } | |
| 606 | ||
| 607 | /** | |
| 608 | * Returns the caret position within the current paragraph. | |
| 609 | * | |
| 610 | * @return A value from 0 to the length of the current paragraph. | |
| 611 | */ | |
| 612 | private int getCaretColumn() { | |
| 613 | return mTextArea.getCaretColumn(); | |
| 614 | } | |
| 615 | ||
| 616 | @Override | |
| 617 | public IndexRange getCaretWord() { | |
| 618 | final var paragraph = getCaretParagraph(); | |
| 619 | final var length = paragraph.length(); | |
| 620 | final var column = getCaretColumn(); | |
| 621 | ||
| 622 | var began = column; | |
| 623 | var ended = column; | |
| 624 | ||
| 625 | while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | |
| 626 | began--; | |
| 627 | } | |
| 628 | ||
| 629 | while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | |
| 630 | ended++; | |
| 631 | } | |
| 632 | ||
| 633 | final var iterator = BreakIterator.getWordInstance(); | |
| 634 | iterator.setText( paragraph ); | |
| 635 | ||
| 636 | while( began < length && iterator.isBoundary( began + 1 ) ) { | |
| 637 | began++; | |
| 638 | } | |
| 639 | ||
| 640 | while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | |
| 641 | ended--; | |
| 642 | } | |
| 643 | ||
| 644 | final var offset = getCaretDocumentOffset( column ); | |
| 645 | ||
| 646 | return IndexRange.normalize( began + offset, ended + offset ); | |
| 647 | } | |
| 648 | ||
| 649 | private int getCaretDocumentOffset( final int column ) { | |
| 650 | return mTextArea.getCaretPosition() - column; | |
| 651 | } | |
| 652 | ||
| 653 | /** | |
| 654 | * Returns the index of the paragraph where the caret resides. | |
| 655 | * | |
| 656 | * @return A number greater than or equal to 0. | |
| 657 | */ | |
| 658 | private int getCurrentParagraph() { | |
| 659 | return mTextArea.getCurrentParagraph(); | |
| 660 | } | |
| 661 | ||
| 662 | /** | |
| 663 | * Returns the text for the paragraph that contains the caret. | |
| 664 | * | |
| 665 | * @return A non-null string, possibly empty. | |
| 666 | */ | |
| 667 | private String getCaretParagraph() { | |
| 668 | return getText( getCurrentParagraph() ); | |
| 669 | } | |
| 670 | ||
| 671 | @Override | |
| 672 | public String getText( final int paragraph ) { | |
| 673 | return mTextArea.getText( paragraph ); | |
| 674 | } | |
| 675 | ||
| 676 | @Override | |
| 677 | public String getText( final IndexRange indexes ) | |
| 678 | throws IndexOutOfBoundsException { | |
| 679 | return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | |
| 680 | } | |
| 681 | ||
| 682 | @Override | |
| 683 | public void replaceText( final IndexRange indexes, final String s ) { | |
| 684 | mTextArea.replaceText( indexes, s ); | |
| 685 | } | |
| 686 | ||
| 687 | private UndoManager<?> getUndoManager() { | |
| 688 | return mTextArea.getUndoManager(); | |
| 689 | } | |
| 690 | ||
| 691 | /** | |
| 692 | * Returns the path to a {@link Locale}-specific stylesheet. | |
| 693 | * | |
| 694 | * @return A non-null string to inject into the HTML document head. | |
| 695 | */ | |
| 696 | private static String getStylesheetPath( final Locale locale ) { | |
| 697 | return get( | |
| 698 | sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), | |
| 699 | locale.getLanguage(), | |
| 700 | locale.getScript(), | |
| 701 | locale.getCountry() | |
| 702 | ); | |
| 703 | } | |
| 704 | ||
| 705 | private Locale getLocale() { | |
| 706 | return localeProperty().toLocale(); | |
| 707 | } | |
| 708 | ||
| 709 | private LocaleProperty localeProperty() { | |
| 710 | return mWorkspace.localeProperty( KEY_UI_FONT_LOCALE ); | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.editors.markdown; | |
| 3 | ||
| 4 | import com.keenwrite.Caret; | |
| 5 | import com.keenwrite.Constants; | |
| 6 | import com.keenwrite.editors.TextEditor; | |
| 7 | import com.keenwrite.preferences.LocaleProperty; | |
| 8 | import com.keenwrite.preferences.Workspace; | |
| 9 | import com.keenwrite.spelling.impl.TextEditorSpeller; | |
| 10 | import javafx.beans.binding.Bindings; | |
| 11 | import javafx.beans.property.*; | |
| 12 | import javafx.beans.value.ChangeListener; | |
| 13 | import javafx.event.Event; | |
| 14 | import javafx.scene.Node; | |
| 15 | import javafx.scene.control.IndexRange; | |
| 16 | import javafx.scene.input.KeyCode; | |
| 17 | import javafx.scene.input.KeyEvent; | |
| 18 | import javafx.scene.layout.BorderPane; | |
| 19 | import org.fxmisc.flowless.VirtualizedScrollPane; | |
| 20 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 21 | import org.fxmisc.richtext.model.StyleSpans; | |
| 22 | import org.fxmisc.undo.UndoManager; | |
| 23 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 24 | import org.fxmisc.wellbehaved.event.Nodes; | |
| 25 | ||
| 26 | import java.io.File; | |
| 27 | import java.nio.charset.Charset; | |
| 28 | import java.text.BreakIterator; | |
| 29 | import java.util.*; | |
| 30 | import java.util.function.Consumer; | |
| 31 | import java.util.function.Supplier; | |
| 32 | import java.util.regex.Pattern; | |
| 33 | ||
| 34 | import static com.keenwrite.Constants.*; | |
| 35 | import static com.keenwrite.Messages.get; | |
| 36 | import static com.keenwrite.StatusNotifier.clue; | |
| 37 | import static com.keenwrite.preferences.Workspace.*; | |
| 38 | import static java.lang.Character.isWhitespace; | |
| 39 | import static java.lang.Math.max; | |
| 40 | import static java.lang.String.format; | |
| 41 | import static java.util.Collections.singletonList; | |
| 42 | import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | |
| 43 | import static javafx.scene.input.KeyCode.*; | |
| 44 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 45 | import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | |
| 46 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 47 | import static org.apache.commons.lang3.StringUtils.stripEnd; | |
| 48 | import static org.apache.commons.lang3.StringUtils.stripStart; | |
| 49 | import static org.fxmisc.richtext.model.StyleSpans.singleton; | |
| 50 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 51 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 52 | ||
| 53 | /** | |
| 54 | * Responsible for editing Markdown documents. | |
| 55 | */ | |
| 56 | public class MarkdownEditor extends BorderPane implements TextEditor { | |
| 57 | /** | |
| 58 | * Regular expression that matches the type of markup block. This is used | |
| 59 | * when Enter is pressed to continue the block environment. | |
| 60 | */ | |
| 61 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | |
| 62 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 63 | ||
| 64 | /** | |
| 65 | * The text editor. | |
| 66 | */ | |
| 67 | private final StyleClassedTextArea mTextArea = | |
| 68 | new StyleClassedTextArea( false ); | |
| 69 | ||
| 70 | /** | |
| 71 | * Wraps the text editor in scrollbars. | |
| 72 | */ | |
| 73 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 74 | new VirtualizedScrollPane<>( mTextArea ); | |
| 75 | ||
| 76 | private final Workspace mWorkspace; | |
| 77 | ||
| 78 | /** | |
| 79 | * Tracks where the caret is located in this document. This offers observable | |
| 80 | * properties for caret position changes. | |
| 81 | */ | |
| 82 | private final Caret mCaret = createCaret( mTextArea ); | |
| 83 | ||
| 84 | /** | |
| 85 | * File being edited by this editor instance. | |
| 86 | */ | |
| 87 | private File mFile; | |
| 88 | ||
| 89 | /** | |
| 90 | * Set to {@code true} upon text or caret position changes. Value is {@code | |
| 91 | * false} by default. | |
| 92 | */ | |
| 93 | private final BooleanProperty mDirty = new SimpleBooleanProperty(); | |
| 94 | ||
| 95 | /** | |
| 96 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 97 | * either no encoding could be determined or this is a new (empty) file. | |
| 98 | */ | |
| 99 | private final Charset mEncoding; | |
| 100 | ||
| 101 | /** | |
| 102 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 103 | * persisted definitions. | |
| 104 | */ | |
| 105 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 106 | ||
| 107 | public MarkdownEditor( final Workspace workspace ) { | |
| 108 | this( DOCUMENT_DEFAULT, workspace ); | |
| 109 | } | |
| 110 | ||
| 111 | public MarkdownEditor( final File file, final Workspace workspace ) { | |
| 112 | mEncoding = open( mFile = file ); | |
| 113 | mWorkspace = workspace; | |
| 114 | ||
| 115 | initTextArea( mTextArea ); | |
| 116 | initStyle( mTextArea ); | |
| 117 | initScrollPane( mScrollPane ); | |
| 118 | initSpellchecker( mTextArea ); | |
| 119 | initHotKeys(); | |
| 120 | initUndoManager(); | |
| 121 | } | |
| 122 | ||
| 123 | private void initTextArea( final StyleClassedTextArea textArea ) { | |
| 124 | textArea.setWrapText( true ); | |
| 125 | textArea.requestFollowCaret(); | |
| 126 | textArea.moveTo( 0 ); | |
| 127 | ||
| 128 | textArea.textProperty().addListener( ( c, o, n ) -> { | |
| 129 | // Fire, regardless of whether the caret position has changed. | |
| 130 | mDirty.set( false ); | |
| 131 | ||
| 132 | // Prevent a caret position change from raising the dirty bits. | |
| 133 | mDirty.set( true ); | |
| 134 | } ); | |
| 135 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | |
| 136 | // Fire when the caret position has changed and the text has not. | |
| 137 | mDirty.set( true ); | |
| 138 | mDirty.set( false ); | |
| 139 | } ); | |
| 140 | } | |
| 141 | ||
| 142 | private void initStyle( final StyleClassedTextArea textArea ) { | |
| 143 | textArea.getStyleClass().add( "markdown" ); | |
| 144 | ||
| 145 | final var stylesheets = textArea.getStylesheets(); | |
| 146 | stylesheets.add( STYLESHEET_MARKDOWN ); | |
| 147 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 148 | ||
| 149 | localeProperty().addListener( ( c, o, n ) -> { | |
| 150 | if( n != null ) { | |
| 151 | stylesheets.remove( max( 0, stylesheets.size() - 1 ) ); | |
| 152 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 153 | } | |
| 154 | } ); | |
| 155 | ||
| 156 | fontNameProperty().addListener( | |
| 157 | ( c, o, n ) -> { | |
| 158 | mTextArea.setStyle( format( "-fx-font-family: '%s';", getFontName() ) ); | |
| 159 | } | |
| 160 | ); | |
| 161 | ||
| 162 | fontSizeProperty().addListener( | |
| 163 | ( c, o, n ) -> | |
| 164 | mTextArea.setStyle( format( "-fx-font-size: %spt;", getFontSize() ) ) | |
| 165 | ); | |
| 166 | } | |
| 167 | ||
| 168 | private void initScrollPane( | |
| 169 | final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | |
| 170 | scrollpane.setVbarPolicy( ALWAYS ); | |
| 171 | setCenter( scrollpane ); | |
| 172 | } | |
| 173 | ||
| 174 | private void initSpellchecker( final StyleClassedTextArea textarea ) { | |
| 175 | final var speller = new TextEditorSpeller(); | |
| 176 | speller.checkDocument( textarea ); | |
| 177 | speller.checkParagraphs( textarea ); | |
| 178 | } | |
| 179 | ||
| 180 | private void initHotKeys() { | |
| 181 | addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | |
| 182 | addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | |
| 183 | addEventListener( keyPressed( TAB ), this::tab ); | |
| 184 | addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | |
| 185 | } | |
| 186 | ||
| 187 | private void initUndoManager() { | |
| 188 | final var undoManager = getUndoManager(); | |
| 189 | final var markedPosition = undoManager.atMarkedPositionProperty(); | |
| 190 | ||
| 191 | undoManager.forgetHistory(); | |
| 192 | undoManager.mark(); | |
| 193 | mModified.bind( Bindings.not( markedPosition ) ); | |
| 194 | } | |
| 195 | ||
| 196 | @Override | |
| 197 | public void moveTo( final int offset ) { | |
| 198 | assert 0 <= offset && offset <= mTextArea.getLength(); | |
| 199 | mTextArea.moveTo( offset ); | |
| 200 | mTextArea.requestFollowCaret(); | |
| 201 | } | |
| 202 | ||
| 203 | /** | |
| 204 | * Delegate the focus request to the text area itself. | |
| 205 | */ | |
| 206 | @Override | |
| 207 | public void requestFocus() { | |
| 208 | mTextArea.requestFocus(); | |
| 209 | } | |
| 210 | ||
| 211 | @Override | |
| 212 | public void setText( final String text ) { | |
| 213 | mTextArea.clear(); | |
| 214 | mTextArea.appendText( text ); | |
| 215 | mTextArea.getUndoManager().mark(); | |
| 216 | } | |
| 217 | ||
| 218 | @Override | |
| 219 | public String getText() { | |
| 220 | return mTextArea.getText(); | |
| 221 | } | |
| 222 | ||
| 223 | @Override | |
| 224 | public Charset getEncoding() { | |
| 225 | return mEncoding; | |
| 226 | } | |
| 227 | ||
| 228 | @Override | |
| 229 | public File getFile() { | |
| 230 | return mFile; | |
| 231 | } | |
| 232 | ||
| 233 | @Override | |
| 234 | public void rename( final File file ) { | |
| 235 | mFile = file; | |
| 236 | } | |
| 237 | ||
| 238 | @Override | |
| 239 | public void undo() { | |
| 240 | final var manager = getUndoManager(); | |
| 241 | xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | |
| 242 | } | |
| 243 | ||
| 244 | @Override | |
| 245 | public void redo() { | |
| 246 | final var manager = getUndoManager(); | |
| 247 | xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | |
| 248 | } | |
| 249 | ||
| 250 | /** | |
| 251 | * Performs an undo or redo action, if possible, otherwise displays an error | |
| 252 | * message to the user. | |
| 253 | * | |
| 254 | * @param ready Answers whether the action can be executed. | |
| 255 | * @param action The action to execute. | |
| 256 | * @param key The informational message key having a value to display if | |
| 257 | * the {@link Supplier} is not ready. | |
| 258 | */ | |
| 259 | private void xxdo( | |
| 260 | final Supplier<Boolean> ready, final Runnable action, final String key ) { | |
| 261 | if( ready.get() ) { | |
| 262 | action.run(); | |
| 263 | } | |
| 264 | else { | |
| 265 | clue( key ); | |
| 266 | } | |
| 267 | } | |
| 268 | ||
| 269 | @Override | |
| 270 | public void cut() { | |
| 271 | final var selected = mTextArea.getSelectedText(); | |
| 272 | ||
| 273 | // Emulate selecting the current line by firing Home then Shift+Down Arrow. | |
| 274 | if( selected == null || selected.isEmpty() ) { | |
| 275 | // Note: mTextArea.selectLine() does not select empty lines. | |
| 276 | mTextArea.fireEvent( keyEvent( HOME, false ) ); | |
| 277 | mTextArea.fireEvent( keyEvent( DOWN, true ) ); | |
| 278 | } | |
| 279 | ||
| 280 | mTextArea.cut(); | |
| 281 | } | |
| 282 | ||
| 283 | private Event keyEvent( final KeyCode code, final boolean shift ) { | |
| 284 | return new KeyEvent( | |
| 285 | KEY_PRESSED, "", "", code, shift, false, false, false | |
| 286 | ); | |
| 287 | } | |
| 288 | ||
| 289 | @Override | |
| 290 | public void copy() { | |
| 291 | mTextArea.copy(); | |
| 292 | } | |
| 293 | ||
| 294 | @Override | |
| 295 | public void paste() { | |
| 296 | mTextArea.paste(); | |
| 297 | } | |
| 298 | ||
| 299 | @Override | |
| 300 | public void selectAll() { | |
| 301 | mTextArea.selectAll(); | |
| 302 | } | |
| 303 | ||
| 304 | @Override | |
| 305 | public void bold() { | |
| 306 | enwrap( "**" ); | |
| 307 | } | |
| 308 | ||
| 309 | @Override | |
| 310 | public void italic() { | |
| 311 | enwrap( "*" ); | |
| 312 | } | |
| 313 | ||
| 314 | @Override | |
| 315 | public void superscript() { | |
| 316 | enwrap( "^" ); | |
| 317 | } | |
| 318 | ||
| 319 | @Override | |
| 320 | public void subscript() { | |
| 321 | enwrap( "~" ); | |
| 322 | } | |
| 323 | ||
| 324 | @Override | |
| 325 | public void strikethrough() { | |
| 326 | enwrap( "~~" ); | |
| 327 | } | |
| 328 | ||
| 329 | @Override | |
| 330 | public void blockquote() { | |
| 331 | block( "> " ); | |
| 332 | } | |
| 333 | ||
| 334 | @Override | |
| 335 | public void code() { | |
| 336 | enwrap( "`" ); | |
| 337 | } | |
| 338 | ||
| 339 | @Override | |
| 340 | public void fencedCodeBlock() { | |
| 341 | final var key = "App.action.insert.fenced_code_block.prompt.text"; | |
| 342 | ||
| 343 | // TODO: Introduce sample text if nothing is selected. | |
| 344 | //enwrap( "\n\n```\n", "\n```\n\n", get( key ) ); | |
| 345 | } | |
| 346 | ||
| 347 | @Override | |
| 348 | public void heading( final int level ) { | |
| 349 | final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | |
| 350 | block( format( "%s ", hashes ) ); | |
| 351 | } | |
| 352 | ||
| 353 | @Override | |
| 354 | public void unorderedList() { | |
| 355 | block( "* " ); | |
| 356 | } | |
| 357 | ||
| 358 | @Override | |
| 359 | public void orderedList() { | |
| 360 | block( "1. " ); | |
| 361 | } | |
| 362 | ||
| 363 | @Override | |
| 364 | public void horizontalRule() { | |
| 365 | block( format( "---%n%n" ) ); | |
| 366 | } | |
| 367 | ||
| 368 | @Override | |
| 369 | public Node getNode() { | |
| 370 | return this; | |
| 371 | } | |
| 372 | ||
| 373 | @Override | |
| 374 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 375 | return mModified; | |
| 376 | } | |
| 377 | ||
| 378 | @Override | |
| 379 | public void clearModifiedProperty() { | |
| 380 | getUndoManager().mark(); | |
| 381 | } | |
| 382 | ||
| 383 | @Override | |
| 384 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 385 | return mScrollPane; | |
| 386 | } | |
| 387 | ||
| 388 | @Override | |
| 389 | public StyleClassedTextArea getTextArea() { | |
| 390 | return mTextArea; | |
| 391 | } | |
| 392 | ||
| 393 | private final Map<String, IndexRange> mStyles = new HashMap<>(); | |
| 394 | ||
| 395 | @Override | |
| 396 | public void stylize( final IndexRange range, final String style ) { | |
| 397 | final var began = range.getStart(); | |
| 398 | final var ended = range.getEnd() + 1; | |
| 399 | ||
| 400 | assert 0 <= began && began <= ended; | |
| 401 | assert style != null; | |
| 402 | ||
| 403 | // TODO: Ensure spell check and find highlights can coexist. | |
| 404 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 405 | // System.out.println( "SPANS: " + spans ); | |
| 406 | ||
| 407 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 408 | // mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | |
| 409 | // ) ); | |
| 410 | ||
| 411 | // final var builder = new StyleSpansBuilder<Collection<String>>(); | |
| 412 | // builder.add( singleton( style ), range.getLength() + 1 ); | |
| 413 | // mTextArea.setStyleSpans( began, builder.create() ); | |
| 414 | ||
| 415 | // final var s = mTextArea.getStyleSpans( began, ended ); | |
| 416 | // System.out.println( "STYLES: " +s ); | |
| 417 | ||
| 418 | mStyles.put( style, range ); | |
| 419 | mTextArea.setStyleClass( began, ended, style ); | |
| 420 | ||
| 421 | // Ensure that whenever the user interacts with the text that the found | |
| 422 | // word will have its highlighting removed. The handler removes itself. | |
| 423 | // This won't remove the highlighting if the caret position moves by mouse. | |
| 424 | final var handler = mTextArea.getOnKeyPressed(); | |
| 425 | mTextArea.setOnKeyPressed( ( event ) -> { | |
| 426 | mTextArea.setOnKeyPressed( handler ); | |
| 427 | unstylize( style ); | |
| 428 | } ); | |
| 429 | ||
| 430 | //mTextArea.setStyleSpans(began, ended, s); | |
| 431 | } | |
| 432 | ||
| 433 | private static StyleSpans<Collection<String>> merge( | |
| 434 | StyleSpans<Collection<String>> spans, int len, String style ) { | |
| 435 | spans = spans.overlay( | |
| 436 | singleton( singletonList( style ), len ), | |
| 437 | ( bottomSpan, list ) -> { | |
| 438 | final List<String> l = | |
| 439 | new ArrayList<>( bottomSpan.size() + list.size() ); | |
| 440 | l.addAll( bottomSpan ); | |
| 441 | l.addAll( list ); | |
| 442 | return l; | |
| 443 | } ); | |
| 444 | ||
| 445 | return spans; | |
| 446 | } | |
| 447 | ||
| 448 | @Override | |
| 449 | public void unstylize( final String style ) { | |
| 450 | final var indexes = mStyles.remove( style ); | |
| 451 | if( indexes != null ) { | |
| 452 | mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | |
| 453 | } | |
| 454 | } | |
| 455 | ||
| 456 | @Override | |
| 457 | public Caret getCaret() { | |
| 458 | return mCaret; | |
| 459 | } | |
| 460 | ||
| 461 | private Caret createCaret( final StyleClassedTextArea editor ) { | |
| 462 | return Caret | |
| 463 | .builder() | |
| 464 | .with( Caret.Mutator::setEditor, editor ) | |
| 465 | .build(); | |
| 466 | } | |
| 467 | ||
| 468 | /** | |
| 469 | * This method adds listeners to editor events. | |
| 470 | * | |
| 471 | * @param <T> The event type. | |
| 472 | * @param <U> The consumer type for the given event type. | |
| 473 | * @param event The event of interest. | |
| 474 | * @param consumer The method to call when the event happens. | |
| 475 | */ | |
| 476 | public <T extends Event, U extends T> void addEventListener( | |
| 477 | final EventPattern<? super T, ? extends U> event, | |
| 478 | final Consumer<? super U> consumer ) { | |
| 479 | Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | |
| 480 | } | |
| 481 | ||
| 482 | @SuppressWarnings( "unused" ) | |
| 483 | private void onEnterPressed( final KeyEvent event ) { | |
| 484 | final var currentLine = getCaretParagraph(); | |
| 485 | final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | |
| 486 | ||
| 487 | // By default, insert a new line by itself. | |
| 488 | String newText = NEWLINE; | |
| 489 | ||
| 490 | // If the pattern was matched then determine what block type to continue. | |
| 491 | if( matcher.matches() ) { | |
| 492 | if( matcher.group( 2 ).isEmpty() ) { | |
| 493 | final var pos = mTextArea.getCaretPosition(); | |
| 494 | mTextArea.selectRange( pos - currentLine.length(), pos ); | |
| 495 | } | |
| 496 | else { | |
| 497 | // Indent the new line with the same whitespace characters and | |
| 498 | // list markers as current line. This ensures that the indentation | |
| 499 | // is propagated. | |
| 500 | newText = newText.concat( matcher.group( 1 ) ); | |
| 501 | } | |
| 502 | } | |
| 503 | ||
| 504 | mTextArea.replaceSelection( newText ); | |
| 505 | } | |
| 506 | ||
| 507 | private void cut( final KeyEvent event ) { | |
| 508 | cut(); | |
| 509 | } | |
| 510 | ||
| 511 | private void tab( final KeyEvent event ) { | |
| 512 | final var range = mTextArea.selectionProperty().getValue(); | |
| 513 | final var sb = new StringBuilder( 1024 ); | |
| 514 | ||
| 515 | if( range.getLength() > 0 ) { | |
| 516 | final var selection = mTextArea.getSelectedText(); | |
| 517 | ||
| 518 | selection.lines().forEach( | |
| 519 | ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 520 | ); | |
| 521 | } | |
| 522 | else { | |
| 523 | sb.append( "\t" ); | |
| 524 | } | |
| 525 | ||
| 526 | mTextArea.replaceSelection( sb.toString() ); | |
| 527 | } | |
| 528 | ||
| 529 | private void untab( final KeyEvent event ) { | |
| 530 | final var range = mTextArea.selectionProperty().getValue(); | |
| 531 | ||
| 532 | if( range.getLength() > 0 ) { | |
| 533 | final var selection = mTextArea.getSelectedText(); | |
| 534 | final var sb = new StringBuilder( selection.length() ); | |
| 535 | ||
| 536 | selection.lines().forEach( | |
| 537 | ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | |
| 538 | .append( NEWLINE ) | |
| 539 | ); | |
| 540 | ||
| 541 | mTextArea.replaceSelection( sb.toString() ); | |
| 542 | } | |
| 543 | else { | |
| 544 | final var p = getCaretParagraph(); | |
| 545 | ||
| 546 | if( p.startsWith( "\t" ) ) { | |
| 547 | mTextArea.selectParagraph(); | |
| 548 | mTextArea.replaceSelection( p.substring( 1 ) ); | |
| 549 | } | |
| 550 | } | |
| 551 | } | |
| 552 | ||
| 553 | /** | |
| 554 | * Observers may listen for changes to the property returned from this method | |
| 555 | * to receive notifications when either the text or caret have changed. This | |
| 556 | * should not be used to track whether the text has been modified. | |
| 557 | */ | |
| 558 | public void addDirtyListener( ChangeListener<Boolean> listener ) { | |
| 559 | mDirty.addListener( listener ); | |
| 560 | } | |
| 561 | ||
| 562 | /** | |
| 563 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 564 | * | |
| 565 | * @param token The beginning and ending token for enclosing the text. | |
| 566 | */ | |
| 567 | private void enwrap( final String token ) { | |
| 568 | enwrap( token, token ); | |
| 569 | } | |
| 570 | ||
| 571 | /** | |
| 572 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 573 | * | |
| 574 | * @param began The beginning token for enclosing the text. | |
| 575 | * @param ended The ending token for enclosing the text. | |
| 576 | */ | |
| 577 | private void enwrap( final String began, String ended ) { | |
| 578 | // Ensure selected text takes precedence over the word at caret position. | |
| 579 | final var selected = mTextArea.selectionProperty().getValue(); | |
| 580 | final var range = selected.getLength() == 0 | |
| 581 | ? getCaretWord() | |
| 582 | : selected; | |
| 583 | String text = mTextArea.getText( range ); | |
| 584 | ||
| 585 | int length = range.getLength(); | |
| 586 | text = stripStart( text, null ); | |
| 587 | final int beganIndex = range.getStart() + (length - text.length()); | |
| 588 | ||
| 589 | length = text.length(); | |
| 590 | text = stripEnd( text, null ); | |
| 591 | final int endedIndex = range.getEnd() - (length - text.length()); | |
| 592 | ||
| 593 | mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | |
| 594 | } | |
| 595 | ||
| 596 | /** | |
| 597 | * Inserts the given block-level markup at the current caret position | |
| 598 | * within the document. This will prepend two blank lines to ensure that | |
| 599 | * the block element begins at the start of a new line. | |
| 600 | * | |
| 601 | * @param markup The text to insert at the caret. | |
| 602 | */ | |
| 603 | private void block( final String markup ) { | |
| 604 | final int pos = mTextArea.getCaretPosition(); | |
| 605 | mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | |
| 606 | } | |
| 607 | ||
| 608 | /** | |
| 609 | * Returns the caret position within the current paragraph. | |
| 610 | * | |
| 611 | * @return A value from 0 to the length of the current paragraph. | |
| 612 | */ | |
| 613 | private int getCaretColumn() { | |
| 614 | return mTextArea.getCaretColumn(); | |
| 615 | } | |
| 616 | ||
| 617 | @Override | |
| 618 | public IndexRange getCaretWord() { | |
| 619 | final var paragraph = getCaretParagraph(); | |
| 620 | final var length = paragraph.length(); | |
| 621 | final var column = getCaretColumn(); | |
| 622 | ||
| 623 | var began = column; | |
| 624 | var ended = column; | |
| 625 | ||
| 626 | while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | |
| 627 | began--; | |
| 628 | } | |
| 629 | ||
| 630 | while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | |
| 631 | ended++; | |
| 632 | } | |
| 633 | ||
| 634 | final var iterator = BreakIterator.getWordInstance(); | |
| 635 | iterator.setText( paragraph ); | |
| 636 | ||
| 637 | while( began < length && iterator.isBoundary( began + 1 ) ) { | |
| 638 | began++; | |
| 639 | } | |
| 640 | ||
| 641 | while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | |
| 642 | ended--; | |
| 643 | } | |
| 644 | ||
| 645 | final var offset = getCaretDocumentOffset( column ); | |
| 646 | ||
| 647 | return IndexRange.normalize( began + offset, ended + offset ); | |
| 648 | } | |
| 649 | ||
| 650 | private int getCaretDocumentOffset( final int column ) { | |
| 651 | return mTextArea.getCaretPosition() - column; | |
| 652 | } | |
| 653 | ||
| 654 | /** | |
| 655 | * Returns the index of the paragraph where the caret resides. | |
| 656 | * | |
| 657 | * @return A number greater than or equal to 0. | |
| 658 | */ | |
| 659 | private int getCurrentParagraph() { | |
| 660 | return mTextArea.getCurrentParagraph(); | |
| 661 | } | |
| 662 | ||
| 663 | /** | |
| 664 | * Returns the text for the paragraph that contains the caret. | |
| 665 | * | |
| 666 | * @return A non-null string, possibly empty. | |
| 667 | */ | |
| 668 | private String getCaretParagraph() { | |
| 669 | return getText( getCurrentParagraph() ); | |
| 670 | } | |
| 671 | ||
| 672 | @Override | |
| 673 | public String getText( final int paragraph ) { | |
| 674 | return mTextArea.getText( paragraph ); | |
| 675 | } | |
| 676 | ||
| 677 | @Override | |
| 678 | public String getText( final IndexRange indexes ) | |
| 679 | throws IndexOutOfBoundsException { | |
| 680 | return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | |
| 681 | } | |
| 682 | ||
| 683 | @Override | |
| 684 | public void replaceText( final IndexRange indexes, final String s ) { | |
| 685 | mTextArea.replaceText( indexes, s ); | |
| 686 | } | |
| 687 | ||
| 688 | private UndoManager<?> getUndoManager() { | |
| 689 | return mTextArea.getUndoManager(); | |
| 690 | } | |
| 691 | ||
| 692 | /** | |
| 693 | * Returns the path to a {@link Locale}-specific stylesheet. | |
| 694 | * | |
| 695 | * @return A non-null string to inject into the HTML document head. | |
| 696 | */ | |
| 697 | private static String getStylesheetPath( final Locale locale ) { | |
| 698 | return get( | |
| 699 | sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), | |
| 700 | locale.getLanguage(), | |
| 701 | locale.getScript(), | |
| 702 | locale.getCountry() | |
| 703 | ); | |
| 704 | } | |
| 705 | ||
| 706 | private Locale getLocale() { | |
| 707 | return localeProperty().toLocale(); | |
| 708 | } | |
| 709 | ||
| 710 | private LocaleProperty localeProperty() { | |
| 711 | return mWorkspace.localeProperty( KEY_LANG_LOCALE ); | |
| 712 | } | |
| 713 | ||
| 714 | private String getFontName() { | |
| 715 | return fontNameProperty().get(); | |
| 716 | } | |
| 717 | ||
| 718 | private StringProperty fontNameProperty() { | |
| 719 | return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME ); | |
| 711 | 720 | } |
| 712 | 721 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.exceptions; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.io; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.io; |
| 3 | 3 | |
| 4 | 4 | import java.net.MalformedURLException; |
| 5 | 5 | import java.net.URI; |
| 6 | 6 | import java.net.URL; |
| 7 | 7 | import java.net.http.HttpClient; |
| 8 | 8 | import java.net.http.HttpRequest; |
| 9 | 9 | |
| 10 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 10 | import static com.keenwrite.StatusNotifier.clue; | |
| 11 | 11 | import static com.keenwrite.io.MediaType.UNDEFINED; |
| 12 | 12 | import static java.net.http.HttpClient.Redirect.NORMAL; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.io; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.io; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.predicates; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 | |
| 4 | import javafx.beans.property.SimpleListProperty; | |
| 5 | 4 | import javafx.beans.property.SimpleObjectProperty; |
| 6 | 5 | import javafx.collections.ObservableList; |
| 7 | 6 | |
| 8 | 7 | import java.util.LinkedHashMap; |
| 9 | 8 | import java.util.Locale; |
| 10 | 9 | import java.util.Map; |
| 11 | 10 | import java.util.Objects; |
| 12 | 11 | |
| 13 | 12 | import static com.keenwrite.Constants.LOCALE_DEFAULT; |
| 13 | import static com.keenwrite.preferences.Workspace.listProperty; | |
| 14 | 14 | import static java.util.Locale.forLanguageTag; |
| 15 | import static javafx.collections.FXCollections.observableArrayList; | |
| 16 | 15 | |
| 17 | 16 | public class LocaleProperty extends SimpleObjectProperty<String> { |
| ... | ||
| 81 | 80 | |
| 82 | 81 | public static ObservableList<String> localeListProperty() { |
| 83 | return new SimpleListProperty<>( observableArrayList( sLocales.keySet() ) ); | |
| 82 | return listProperty( sLocales.keySet() ); | |
| 84 | 83 | } |
| 85 | 84 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 | |
| 4 | 4 | import com.dlsc.formsfx.model.structure.StringField; |
| 5 | 5 | import com.dlsc.preferencesfx.PreferencesFx; |
| 6 | 6 | import com.dlsc.preferencesfx.PreferencesFxEvent; |
| 7 | 7 | import com.dlsc.preferencesfx.model.Category; |
| 8 | 8 | import com.dlsc.preferencesfx.model.Group; |
| 9 | 9 | import com.dlsc.preferencesfx.model.Setting; |
| 10 | import com.dlsc.preferencesfx.view.NavigationView; | |
| 10 | 11 | import javafx.beans.property.DoubleProperty; |
| 11 | 12 | import javafx.beans.property.ObjectProperty; |
| 12 | 13 | import javafx.beans.property.StringProperty; |
| 13 | 14 | import javafx.event.EventHandler; |
| 14 | 15 | import javafx.scene.Node; |
| 16 | import javafx.scene.control.Button; | |
| 17 | import javafx.scene.control.DialogPane; | |
| 15 | 18 | import javafx.scene.control.Label; |
| 19 | import org.controlsfx.control.MasterDetailPane; | |
| 16 | 20 | |
| 17 | 21 | import java.io.File; |
| 18 | 22 | |
| 23 | import static com.dlsc.formsfx.model.structure.Field.ofStringType; | |
| 24 | import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED; | |
| 19 | 25 | import static com.keenwrite.Constants.ICON_DIALOG; |
| 20 | 26 | import static com.keenwrite.Messages.get; |
| 21 | 27 | import static com.keenwrite.preferences.LocaleProperty.localeListProperty; |
| 22 | 28 | import static com.keenwrite.preferences.Workspace.*; |
| 29 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 30 | import static javafx.scene.control.ButtonType.OK; | |
| 23 | 31 | |
| 24 | 32 | /** |
| ... | ||
| 37 | 45 | // All properties must be initialized before creating the dialog. |
| 38 | 46 | mPreferencesFx = createPreferencesFx(); |
| 47 | ||
| 48 | initKeyEventHandler( mPreferencesFx ); | |
| 39 | 49 | } |
| 40 | 50 | |
| ... | ||
| 62 | 72 | public void addSaveEventHandler( |
| 63 | 73 | final EventHandler<? super PreferencesFxEvent> eventHandler ) { |
| 64 | final var eventType = PreferencesFxEvent.EVENT_PREFERENCES_SAVED; | |
| 65 | getPreferencesFx().addEventHandler( eventType, eventHandler ); | |
| 74 | getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler ); | |
| 75 | } | |
| 76 | ||
| 77 | private StringField createFontNameField( | |
| 78 | final StringProperty fontName, final DoubleProperty fontSize ) { | |
| 79 | final var control = new SimpleFontControl( "Change" ); | |
| 80 | control.fontSizeProperty().addListener( ( c, o, n ) -> { | |
| 81 | if( n != null ) { | |
| 82 | fontSize.set( n.doubleValue() ); | |
| 83 | } | |
| 84 | } ); | |
| 85 | return ofStringType( fontName ).render( control ); | |
| 66 | 86 | } |
| 67 | 87 | |
| ... | ||
| 75 | 95 | * @return A new instance of preferences for users to edit. |
| 76 | 96 | */ |
| 77 | @SuppressWarnings( "unchecked" ) | |
| 78 | 97 | private PreferencesFx createPreferencesFx() { |
| 79 | final Setting<StringField, StringProperty> scriptSetting = | |
| 80 | Setting.of( "Script", stringProperty( KEY_R_SCRIPT ) ); | |
| 81 | final StringField field = scriptSetting.getElement(); | |
| 82 | field.multiline( true ); | |
| 83 | ||
| 84 | 98 | return PreferencesFx.of( |
| 85 | 99 | new XmlStorageHandler(), |
| 86 | 100 | Category.of( |
| 87 | 101 | get( KEY_R ), |
| 88 | 102 | Group.of( |
| 89 | 103 | get( KEY_R_DIR ), |
| 90 | 104 | Setting.of( label( KEY_R_DIR, |
| 91 | 105 | stringProperty( KEY_DEF_DELIM_BEGAN ).get(), |
| 92 | 106 | stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ), |
| 93 | Setting.of( title( KEY_R_DIR ), fileProperty( KEY_R_DIR ), true ) | |
| 107 | Setting.of( title( KEY_R_DIR ), | |
| 108 | fileProperty( KEY_R_DIR ), true ) | |
| 94 | 109 | ), |
| 95 | 110 | Group.of( |
| 96 | 111 | get( KEY_R_SCRIPT ), |
| 97 | 112 | Setting.of( label( KEY_R_SCRIPT ) ), |
| 98 | scriptSetting | |
| 113 | createScriptSetting() | |
| 99 | 114 | ), |
| 100 | 115 | Group.of( |
| ... | ||
| 117 | 132 | Setting.of( label( KEY_IMAGES_DIR ) ), |
| 118 | 133 | Setting.of( title( KEY_IMAGES_DIR ), |
| 119 | fileProperty( KEY_IMAGES_DIR ), | |
| 120 | true ) | |
| 134 | fileProperty( KEY_IMAGES_DIR ), true ) | |
| 121 | 135 | ), |
| 122 | 136 | Group.of( |
| ... | ||
| 133 | 147 | Setting.of( label( KEY_DEF_PATH ) ), |
| 134 | 148 | Setting.of( title( KEY_DEF_PATH ), |
| 135 | fileProperty( KEY_DEF_PATH ), | |
| 136 | false ) | |
| 149 | fileProperty( KEY_DEF_PATH ), false ) | |
| 137 | 150 | ), |
| 138 | 151 | Group.of( |
| ... | ||
| 151 | 164 | Category.of( |
| 152 | 165 | get( KEY_UI_FONT ), |
| 153 | Group.of( | |
| 154 | get( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 155 | Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 156 | Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 157 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ) | |
| 158 | ), | |
| 159 | 166 | Group.of( |
| 160 | get( KEY_UI_FONT_EDITOR_SIZE ), | |
| 167 | get( KEY_UI_FONT_EDITOR ), | |
| 168 | Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 169 | Setting.of( title( KEY_UI_FONT_EDITOR_NAME ), | |
| 170 | createFontNameField( | |
| 171 | stringProperty( KEY_UI_FONT_EDITOR_NAME ), | |
| 172 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 173 | stringProperty( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 161 | 174 | Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ), |
| 162 | 175 | Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ), |
| 163 | 176 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ) |
| 164 | 177 | ), |
| 165 | 178 | Group.of( |
| 166 | get( KEY_UI_FONT_LOCALE ), | |
| 167 | Setting.of( label( KEY_UI_FONT_LOCALE ) ), | |
| 168 | Setting.of( title( KEY_UI_FONT_LOCALE ), | |
| 179 | get( KEY_UI_FONT_PREVIEW ), | |
| 180 | Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 181 | Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ), | |
| 182 | createFontNameField( | |
| 183 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ), | |
| 184 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 185 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 186 | Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 187 | Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 188 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 189 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 190 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 191 | createFontNameField( | |
| 192 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 193 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 194 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 195 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 196 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ), | |
| 197 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ) | |
| 198 | ) | |
| 199 | ), | |
| 200 | Category.of( | |
| 201 | get( KEY_LANGUAGE ), | |
| 202 | Group.of( | |
| 203 | get( KEY_LANG_LOCALE ), | |
| 204 | Setting.of( label( KEY_LANG_LOCALE ) ), | |
| 205 | Setting.of( title( KEY_LANG_LOCALE ), | |
| 169 | 206 | localeListProperty(), |
| 170 | localeProperty( KEY_UI_FONT_LOCALE ) ) | |
| 207 | localeProperty( KEY_LANG_LOCALE ) ) | |
| 171 | 208 | ) |
| 172 | 209 | ) |
| 173 | 210 | ).instantPersistent( false ).dialogIcon( ICON_DIALOG ); |
| 211 | } | |
| 212 | ||
| 213 | @SuppressWarnings( "unchecked" ) | |
| 214 | private Setting<StringField, StringProperty> createScriptSetting() { | |
| 215 | final Setting<StringField, StringProperty> scriptSetting = | |
| 216 | Setting.of( "Script", stringProperty( KEY_R_SCRIPT ) ); | |
| 217 | final var field = scriptSetting.getElement(); | |
| 218 | field.multiline( true ); | |
| 219 | ||
| 220 | return scriptSetting; | |
| 221 | } | |
| 222 | ||
| 223 | private void initKeyEventHandler( final PreferencesFx preferences ) { | |
| 224 | final var view = preferences.getView(); | |
| 225 | final var nodes = view.getChildrenUnmodifiable(); | |
| 226 | final var master = (MasterDetailPane) nodes.get( 0 ); | |
| 227 | final var detail = (NavigationView) master.getDetailNode(); | |
| 228 | final var pane = (DialogPane) view.getParent(); | |
| 229 | ||
| 230 | detail.setOnKeyReleased( ( key ) -> { | |
| 231 | switch( key.getCode() ) { | |
| 232 | case ENTER -> ((Button) pane.lookupButton( OK )).fire(); | |
| 233 | case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); | |
| 234 | } | |
| 235 | } ); | |
| 174 | 236 | } |
| 175 | 237 | |
| 1 | /* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import com.dlsc.formsfx.model.structure.StringField; | |
| 5 | import com.dlsc.preferencesfx.formsfx.view.controls.SimpleControl; | |
| 6 | import javafx.beans.property.DoubleProperty; | |
| 7 | import javafx.beans.property.SimpleDoubleProperty; | |
| 8 | import javafx.scene.control.Button; | |
| 9 | import javafx.scene.control.ListView; | |
| 10 | import javafx.scene.control.TextField; | |
| 11 | import javafx.scene.input.KeyEvent; | |
| 12 | import javafx.scene.layout.HBox; | |
| 13 | import javafx.scene.layout.Region; | |
| 14 | import javafx.scene.layout.StackPane; | |
| 15 | import javafx.scene.text.Font; | |
| 16 | import javafx.stage.Stage; | |
| 17 | import org.controlsfx.dialog.FontSelectorDialog; | |
| 18 | ||
| 19 | import static com.keenwrite.Constants.ICON_DIALOG; | |
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 21 | import static java.lang.System.currentTimeMillis; | |
| 22 | import static javafx.geometry.Pos.CENTER_LEFT; | |
| 23 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 24 | import static javafx.scene.control.ButtonType.OK; | |
| 25 | import static javafx.scene.input.KeyCode.ENTER; | |
| 26 | import static javafx.scene.input.KeyCode.ESCAPE; | |
| 27 | import static javafx.scene.layout.Priority.ALWAYS; | |
| 28 | import static javafx.scene.text.Font.font; | |
| 29 | import static javafx.scene.text.Font.getDefault; | |
| 30 | ||
| 31 | /** | |
| 32 | * Responsible for provide users the ability to select a font using a friendly | |
| 33 | * font dialog. | |
| 34 | */ | |
| 35 | public class SimpleFontControl extends SimpleControl<StringField, StackPane> { | |
| 36 | private final Button mButton = new Button(); | |
| 37 | private final String mButtonText; | |
| 38 | private final DoubleProperty mFontSize = new SimpleDoubleProperty(); | |
| 39 | private final TextField mFontName = new TextField(); | |
| 40 | ||
| 41 | public SimpleFontControl( final String buttonText ) { | |
| 42 | mButtonText = buttonText; | |
| 43 | } | |
| 44 | ||
| 45 | @Override | |
| 46 | public void initializeParts() { | |
| 47 | super.initializeParts(); | |
| 48 | ||
| 49 | mFontName.setText( field.getValue() ); | |
| 50 | mFontName.setPromptText( field.placeholderProperty().getValue() ); | |
| 51 | ||
| 52 | final var fieldProperty = field.valueProperty(); | |
| 53 | if( fieldProperty.get().equals( "null" ) ) { | |
| 54 | fieldProperty.set( "" ); | |
| 55 | } | |
| 56 | ||
| 57 | mButton.setText( mButtonText ); | |
| 58 | mButton.setOnAction( event -> { | |
| 59 | final var selected = !fieldProperty.get().trim().isEmpty(); | |
| 60 | var initialFont = getDefault(); | |
| 61 | if( selected ) { | |
| 62 | final var previousValue = fieldProperty.get(); | |
| 63 | initialFont = font( previousValue ); | |
| 64 | } | |
| 65 | ||
| 66 | createFontSelectorDialog( initialFont ) | |
| 67 | .showAndWait() | |
| 68 | .ifPresent( ( font ) -> { | |
| 69 | mFontName.setText( font.getFamily() ); | |
| 70 | mFontSize.set( font.getSize() ); | |
| 71 | } ); | |
| 72 | } ); | |
| 73 | ||
| 74 | node = new StackPane(); | |
| 75 | } | |
| 76 | ||
| 77 | @Override | |
| 78 | public void layoutParts() { | |
| 79 | node.getStyleClass().add( "simple-text-control" ); | |
| 80 | fieldLabel.getStyleClass().addAll( field.getStyleClass() ); | |
| 81 | fieldLabel.getStyleClass().add( "read-only-label" ); | |
| 82 | ||
| 83 | final var box = new HBox(); | |
| 84 | HBox.setHgrow( mFontName, ALWAYS ); | |
| 85 | box.setAlignment( CENTER_LEFT ); | |
| 86 | box.getChildren().addAll( fieldLabel, mFontName, mButton ); | |
| 87 | ||
| 88 | node.getChildren().add( box ); | |
| 89 | } | |
| 90 | ||
| 91 | @Override | |
| 92 | public void setupBindings() { | |
| 93 | super.setupBindings(); | |
| 94 | mFontName.textProperty().bindBidirectional( field.userInputProperty() ); | |
| 95 | } | |
| 96 | ||
| 97 | public DoubleProperty fontSizeProperty() { | |
| 98 | return mFontSize; | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Creates a dialog that displays a list of available font families, | |
| 103 | * sizes, and a button for font selection. | |
| 104 | * | |
| 105 | * @param font The default font to select initially. | |
| 106 | * @return A dialog to help the user select a different {@link Font}. | |
| 107 | */ | |
| 108 | private FontSelectorDialog createFontSelectorDialog( final Font font ) { | |
| 109 | final var dialog = new FontSelectorDialog( font ); | |
| 110 | final var pane = dialog.getDialogPane(); | |
| 111 | final var buttonOk = ((Button) pane.lookupButton( OK )); | |
| 112 | final var buttonCancel = ((Button) pane.lookupButton( CANCEL )); | |
| 113 | ||
| 114 | buttonOk.setDefaultButton( true ); | |
| 115 | buttonCancel.setCancelButton( true ); | |
| 116 | pane.setOnKeyReleased( ( keyEvent ) -> { | |
| 117 | switch( keyEvent.getCode() ) { | |
| 118 | case ENTER -> buttonOk.fire(); | |
| 119 | case ESCAPE -> buttonCancel.fire(); | |
| 120 | } | |
| 121 | } ); | |
| 122 | ||
| 123 | final var stage = (Stage) pane.getScene().getWindow(); | |
| 124 | stage.getIcons().add( ICON_DIALOG ); | |
| 125 | ||
| 126 | final var frontPanel = (Region) pane.getContent(); | |
| 127 | for( final var node : frontPanel.getChildrenUnmodifiable() ) { | |
| 128 | if( node instanceof ListView ) { | |
| 129 | final var listView = (ListView<?>) node; | |
| 130 | final var handler = new ListViewHandler<>( listView ); | |
| 131 | listView.setOnKeyPressed( handler::handle ); | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | return dialog; | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Responsible for handling key presses when selecting a font. Based on | |
| 140 | * <a href="https://stackoverflow.com/a/43604223/59087">Martin Široký</a>'s | |
| 141 | * answer. | |
| 142 | * | |
| 143 | * @param <T> The type of {@link ListView} to search. | |
| 144 | */ | |
| 145 | private static final class ListViewHandler<T> { | |
| 146 | /** | |
| 147 | * Amount of time to wait between key presses that typing a subsequent | |
| 148 | * key is considered part of the same search, in milliseconds. | |
| 149 | */ | |
| 150 | private static final int RESET_DELAY_MS = 1250; | |
| 151 | ||
| 152 | private String mNeedle = ""; | |
| 153 | private int mSearchSkip = 0; | |
| 154 | private long mLastTyped = currentTimeMillis(); | |
| 155 | private final ListView<T> mHaystack; | |
| 156 | ||
| 157 | private ListViewHandler( final ListView<T> listView ) { | |
| 158 | mHaystack = listView; | |
| 159 | } | |
| 160 | ||
| 161 | private void handle( final KeyEvent key ) { | |
| 162 | var ch = key.getText(); | |
| 163 | final var code = key.getCode(); | |
| 164 | ||
| 165 | if( ch == null || ch.isEmpty() || code == ESCAPE || code == ENTER ) { | |
| 166 | return; | |
| 167 | } | |
| 168 | ||
| 169 | ch = ch.toUpperCase(); | |
| 170 | ||
| 171 | if( mNeedle.equals( ch ) ) { | |
| 172 | mSearchSkip++; | |
| 173 | } | |
| 174 | else { | |
| 175 | mNeedle = currentTimeMillis() - mLastTyped > RESET_DELAY_MS | |
| 176 | ? ch : mNeedle + ch; | |
| 177 | } | |
| 178 | ||
| 179 | mLastTyped = currentTimeMillis(); | |
| 180 | ||
| 181 | boolean found = false; | |
| 182 | int skipped = 0; | |
| 183 | ||
| 184 | for( final T item : mHaystack.getItems() ) { | |
| 185 | final var straw = item.toString().toUpperCase(); | |
| 186 | ||
| 187 | if( straw.startsWith( mNeedle ) ) { | |
| 188 | if( mSearchSkip > skipped ) { | |
| 189 | skipped++; | |
| 190 | continue; | |
| 191 | } | |
| 192 | ||
| 193 | mHaystack.getSelectionModel().select( item ); | |
| 194 | final int index = mHaystack.getSelectionModel().getSelectedIndex(); | |
| 195 | mHaystack.getFocusModel().focus( index ); | |
| 196 | mHaystack.scrollTo( index ); | |
| 197 | found = true; | |
| 198 | break; | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | if( !found ) { | |
| 203 | clue( "Main.status.font.search.missing", mNeedle ); | |
| 204 | mSearchSkip = 0; | |
| 205 | } | |
| 206 | } | |
| 207 | } | |
| 208 | } | |
| 1 | 209 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import com.keenwrite.Constants; | |
| 5 | import com.keenwrite.sigils.Tokens; | |
| 6 | import javafx.application.Platform; | |
| 7 | import javafx.beans.property.*; | |
| 8 | import org.apache.commons.configuration2.XMLConfiguration; | |
| 9 | import org.apache.commons.configuration2.builder.fluent.Configurations; | |
| 10 | import org.apache.commons.configuration2.io.FileHandler; | |
| 11 | ||
| 12 | import java.io.File; | |
| 13 | import java.util.HashSet; | |
| 14 | import java.util.LinkedHashSet; | |
| 15 | import java.util.Map; | |
| 16 | import java.util.Set; | |
| 17 | import java.util.function.BiConsumer; | |
| 18 | import java.util.function.BooleanSupplier; | |
| 19 | import java.util.function.Consumer; | |
| 20 | import java.util.function.Function; | |
| 21 | ||
| 22 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; | |
| 23 | import static com.keenwrite.Constants.*; | |
| 24 | import static com.keenwrite.Launcher.getVersion; | |
| 25 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 26 | import static com.keenwrite.preferences.Key.key; | |
| 27 | import static java.util.Map.entry; | |
| 28 | import static javafx.application.Platform.runLater; | |
| 29 | import static javafx.collections.FXCollections.observableSet; | |
| 30 | ||
| 31 | /** | |
| 32 | * Responsible for defining behaviours for separate projects. A workspace has | |
| 33 | * the ability to save and restore a session, including the window dimensions, | |
| 34 | * tab setup, files, and user preferences. | |
| 35 | * <p> | |
| 36 | * The configuration must support hierarchical (nested) configuration nodes | |
| 37 | * to persist the user interface state. Although possible with a flat | |
| 38 | * configuration file, it's not nearly as simple or elegant. | |
| 39 | * </p> | |
| 40 | * <p> | |
| 41 | * Neither JSON nor HOCON support schema validation and versioning, which makes | |
| 42 | * XML the more suitable configuration file format. Schema validation and | |
| 43 | * versioning provide future-proofing and ease of reading and upgrading previous | |
| 44 | * versions of the configuration file. | |
| 45 | * </p> | |
| 46 | * <p> | |
| 47 | * Persistent preferences may be set directly by the user or indirectly by | |
| 48 | * the act of using the application. | |
| 49 | * </p> | |
| 50 | * <p> | |
| 51 | * Note the following definitions: | |
| 52 | * </p> | |
| 53 | * <dl> | |
| 54 | * <dt>File</dt> | |
| 55 | * <dd>References a file name (no path), path, or directory.</dd> | |
| 56 | * <dt>Path</dt> | |
| 57 | * <dd>Fully qualified file name, which includes all parent directories.</dd> | |
| 58 | * <dt>Dir</dt> | |
| 59 | * <dd>Directory without a file name ({@link File#isDirectory()} is true) | |
| 60 | * .</dd> | |
| 61 | * </dl> | |
| 62 | */ | |
| 63 | public class Workspace { | |
| 64 | private static final Key KEY_ROOT = key( "workspace" ); | |
| 65 | ||
| 66 | public static final Key KEY_META = key( KEY_ROOT, "meta" ); | |
| 67 | public static final Key KEY_META_NAME = key( KEY_META, "name" ); | |
| 68 | public static final Key KEY_META_VERSION = key( KEY_META, "version" ); | |
| 69 | ||
| 70 | public static final Key KEY_R = key( KEY_ROOT, "r" ); | |
| 71 | public static final Key KEY_R_SCRIPT = key( KEY_R, "script" ); | |
| 72 | public static final Key KEY_R_DIR = key( KEY_R, "dir" ); | |
| 73 | public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" ); | |
| 74 | public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" ); | |
| 75 | public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" ); | |
| 76 | ||
| 77 | public static final Key KEY_IMAGES = key( KEY_ROOT, "images" ); | |
| 78 | public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" ); | |
| 79 | public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" ); | |
| 80 | ||
| 81 | public static final Key KEY_DEF = key( KEY_ROOT, "definition" ); | |
| 82 | public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" ); | |
| 83 | public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" ); | |
| 84 | public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" ); | |
| 85 | public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" ); | |
| 86 | ||
| 87 | //@formatter:off | |
| 88 | public static final Key KEY_UI = key( KEY_ROOT, "ui" ); | |
| 89 | ||
| 90 | public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" ); | |
| 91 | public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" ); | |
| 92 | public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" ); | |
| 93 | public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" ); | |
| 94 | ||
| 95 | public static final Key KEY_UI_FILES = key( KEY_UI, "files" ); | |
| 96 | public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" ); | |
| 97 | ||
| 98 | public static final Key KEY_UI_FONT = key( KEY_UI, "font" ); | |
| 99 | public static final Key KEY_UI_FONT_LOCALE = key( KEY_UI_FONT, "locale" ); | |
| 100 | public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" ); | |
| 101 | public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" ); | |
| 102 | public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" ); | |
| 103 | public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" ); | |
| 104 | ||
| 105 | public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" ); | |
| 106 | public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" ); | |
| 107 | public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" ); | |
| 108 | public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" ); | |
| 109 | public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" ); | |
| 110 | public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" ); | |
| 111 | public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" ); | |
| 112 | ||
| 113 | private final Map<Key, Property<?>> VALUES = Map.ofEntries( | |
| 114 | entry( KEY_META_VERSION, new SimpleStringProperty( getVersion() ) ), | |
| 115 | entry( KEY_META_NAME, new SimpleStringProperty( "default" ) ), | |
| 116 | ||
| 117 | entry( KEY_R_SCRIPT, new SimpleStringProperty( "" ) ), | |
| 118 | entry( KEY_R_DIR, new FileProperty( USER_DIRECTORY ) ), | |
| 119 | entry( KEY_R_DELIM_BEGAN, new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT ) ), | |
| 120 | entry( KEY_R_DELIM_ENDED, new SimpleStringProperty( R_DELIM_ENDED_DEFAULT ) ), | |
| 121 | ||
| 122 | entry( KEY_IMAGES_DIR, new FileProperty( USER_DIRECTORY ) ), | |
| 123 | entry( KEY_IMAGES_ORDER, new SimpleStringProperty( PERSIST_IMAGES_DEFAULT ) ), | |
| 124 | ||
| 125 | entry( KEY_DEF_PATH, new FileProperty( DEFINITION_DEFAULT ) ), | |
| 126 | entry( KEY_DEF_DELIM_BEGAN, new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ), | |
| 127 | entry( KEY_DEF_DELIM_ENDED, new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT ) ), | |
| 128 | ||
| 129 | entry( KEY_UI_RECENT_DIR, new FileProperty( USER_DIRECTORY ) ), | |
| 130 | entry( KEY_UI_RECENT_DOCUMENT, new FileProperty( DOCUMENT_DEFAULT ) ), | |
| 131 | entry( KEY_UI_RECENT_DEFINITION, new FileProperty( DEFINITION_DEFAULT ) ), | |
| 132 | ||
| 133 | entry( KEY_UI_FONT_LOCALE, new LocaleProperty( LOCALE_DEFAULT ) ), | |
| 134 | entry( KEY_UI_FONT_EDITOR_SIZE, new SimpleDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ), | |
| 135 | entry( KEY_UI_FONT_PREVIEW_SIZE, new SimpleDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ), | |
| 136 | ||
| 137 | entry( KEY_UI_WINDOW_X, new SimpleDoubleProperty( WINDOW_X_DEFAULT ) ), | |
| 138 | entry( KEY_UI_WINDOW_Y, new SimpleDoubleProperty( WINDOW_Y_DEFAULT ) ), | |
| 139 | entry( KEY_UI_WINDOW_W, new SimpleDoubleProperty( WINDOW_W_DEFAULT ) ), | |
| 140 | entry( KEY_UI_WINDOW_H, new SimpleDoubleProperty( WINDOW_H_DEFAULT ) ), | |
| 141 | entry( KEY_UI_WINDOW_MAX, new SimpleBooleanProperty() ), | |
| 142 | entry( KEY_UI_WINDOW_FULL, new SimpleBooleanProperty() ) | |
| 143 | ); | |
| 144 | //@formatter:on | |
| 145 | ||
| 146 | /** | |
| 147 | * Helps instantiate {@link Property} instances for XML configuration items. | |
| 148 | */ | |
| 149 | private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | |
| 150 | Map.of( | |
| 151 | LocaleProperty.class, LocaleProperty::parseLocale, | |
| 152 | SimpleBooleanProperty.class, Boolean::parseBoolean, | |
| 153 | SimpleDoubleProperty.class, Double::parseDouble, | |
| 154 | SimpleFloatProperty.class, Float::parseFloat, | |
| 155 | FileProperty.class, File::new | |
| 156 | ); | |
| 157 | ||
| 158 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 159 | Map.of( | |
| 160 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 161 | ); | |
| 162 | ||
| 163 | private final Map<Key, SetProperty<?>> SETS = Map.ofEntries( | |
| 164 | entry( | |
| 165 | KEY_UI_FILES_PATH, | |
| 166 | new SimpleSetProperty<>( observableSet( new HashSet<>() ) ) | |
| 167 | ) | |
| 168 | ); | |
| 169 | ||
| 170 | /** | |
| 171 | * Creates a new {@link Workspace} that will attempt to load a configuration | |
| 172 | * file. If the configuration file cannot be loaded, the workspace settings | |
| 173 | * will return default values. This allows unit tests to provide an instance | |
| 174 | * of {@link Workspace} when necessary without encountering failures. | |
| 175 | */ | |
| 176 | public Workspace() { | |
| 177 | load(); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Returns a value that represents a setting in the application that the user | |
| 182 | * may configure, either directly or indirectly. | |
| 183 | * | |
| 184 | * @param key The reference to the users' preference stored in deference | |
| 185 | * of app reëntrance. | |
| 186 | * @return An observable property to be persisted. | |
| 187 | */ | |
| 188 | @SuppressWarnings( "unchecked" ) | |
| 189 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 190 | // The type that goes into the map must come out. | |
| 191 | return (U) VALUES.get( key ); | |
| 192 | } | |
| 193 | ||
| 194 | /** | |
| 195 | * Returns a list of values that represent a setting in the application that | |
| 196 | * the user may configure, either directly or indirectly. The property | |
| 197 | * returned is backed by a mutable {@link Set}. | |
| 198 | * | |
| 199 | * @param key The {@link Key} associated with a preference value. | |
| 200 | * @return An observable property to be persisted. | |
| 201 | */ | |
| 202 | @SuppressWarnings( "unchecked" ) | |
| 203 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 204 | // The type that goes into the map must come out. | |
| 205 | return (SetProperty<T>) SETS.get( key ); | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * Returns the {@link Boolean} preference value associated with the given | |
| 210 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 211 | * associated with a value that matches the return type. | |
| 212 | * | |
| 213 | * @param key The {@link Key} associated with a preference value. | |
| 214 | * @return The value associated with the given {@link Key}. | |
| 215 | */ | |
| 216 | public boolean toBoolean( final Key key ) { | |
| 217 | return (Boolean) valuesProperty( key ).getValue(); | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Returns the {@link Double} preference value associated with the given | |
| 222 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 223 | * associated with a value that matches the return type. | |
| 224 | * | |
| 225 | * @param key The {@link Key} associated with a preference value. | |
| 226 | * @return The value associated with the given {@link Key}. | |
| 227 | */ | |
| 228 | public double toDouble( final Key key ) { | |
| 229 | return (Double) valuesProperty( key ).getValue(); | |
| 230 | } | |
| 231 | ||
| 232 | public File toFile( final Key key ) { | |
| 233 | return fileProperty( key ).get(); | |
| 234 | } | |
| 235 | ||
| 236 | public String toString( final Key key ) { | |
| 237 | return stringProperty( key ).get(); | |
| 238 | } | |
| 239 | ||
| 240 | public Tokens toTokens( final Key began, final Key ended ) { | |
| 241 | return new Tokens( stringProperty( began ), stringProperty( ended ) ); | |
| 242 | } | |
| 243 | ||
| 244 | @SuppressWarnings( "SameParameterValue" ) | |
| 245 | public DoubleProperty doubleProperty( final Key key ) { | |
| 246 | return valuesProperty( key ); | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * Returns the {@link File} {@link Property} associated with the given | |
| 251 | * {@link Key} from the internal list of preference values. The caller | |
| 252 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 253 | * {@link Property}. | |
| 254 | * | |
| 255 | * @param key The {@link Key} associated with a preference value. | |
| 256 | * @return The value associated with the given {@link Key}. | |
| 257 | */ | |
| 258 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 259 | return valuesProperty( key ); | |
| 260 | } | |
| 261 | ||
| 262 | public LocaleProperty localeProperty( final Key key ) { | |
| 263 | return valuesProperty( key ); | |
| 264 | } | |
| 265 | ||
| 266 | public StringProperty stringProperty( final Key key ) { | |
| 267 | return valuesProperty( key ); | |
| 268 | } | |
| 269 | ||
| 270 | public void loadValueKeys( final Consumer<Key> consumer ) { | |
| 271 | VALUES.keySet().forEach( consumer ); | |
| 272 | } | |
| 273 | ||
| 274 | public void loadSetKeys( final Consumer<Key> consumer ) { | |
| 275 | SETS.keySet().forEach( consumer ); | |
| 276 | } | |
| 277 | ||
| 278 | /** | |
| 279 | * Calls the given consumer for all single-value keys. For lists, see | |
| 280 | * {@link #saveSets(BiConsumer)}. | |
| 281 | * | |
| 282 | * @param consumer Called to accept each preference key value. | |
| 283 | */ | |
| 284 | public void saveValues( final BiConsumer<Key, Property<?>> consumer ) { | |
| 285 | VALUES.forEach( consumer ); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Calls the given consumer for all multi-value keys. For single items, see | |
| 290 | * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating | |
| 291 | * over the list of items retrieved through this method. | |
| 292 | * | |
| 293 | * @param consumer Called to accept each preference key list. | |
| 294 | */ | |
| 295 | public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) { | |
| 296 | SETS.forEach( consumer ); | |
| 297 | } | |
| 298 | ||
| 299 | /** | |
| 300 | * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | |
| 301 | * providing a value of {@code true} for the {@link BooleanSupplier} to | |
| 302 | * indicate the property changes always take effect. | |
| 303 | * | |
| 304 | * @param key The value to bind to the internal key property. | |
| 305 | * @param property The external property value that sets the internal value. | |
| 306 | */ | |
| 307 | public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | |
| 308 | listen( key, property, () -> true ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Binds a read-only property to a value in the preferences. This allows | |
| 313 | * user interface properties to change and the preferences will be | |
| 314 | * synchronized automatically. | |
| 315 | * <p> | |
| 316 | * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | |
| 317 | * application window states are finished before assessing whether property | |
| 318 | * changes should be applied. Without this, exiting the application while the | |
| 319 | * window is maximized would persist the window's maximum dimensions, | |
| 320 | * preventing restoration to its prior, non-maximum size. | |
| 321 | * </p> | |
| 322 | * | |
| 323 | * @param key The value to bind to the internal key property. | |
| 324 | * @param property The external property value that sets the internal value. | |
| 325 | * @param enabled Indicates whether property changes should be applied. | |
| 326 | */ | |
| 327 | public <T> void listen( | |
| 328 | final Key key, | |
| 329 | final ReadOnlyProperty<T> property, | |
| 330 | final BooleanSupplier enabled ) { | |
| 331 | property.addListener( | |
| 332 | ( c, o, n ) -> runLater( () -> { | |
| 333 | if( enabled.getAsBoolean() ) { | |
| 334 | valuesProperty( key ).setValue( n ); | |
| 335 | } | |
| 336 | } ) | |
| 337 | ); | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Saves the current workspace. | |
| 342 | */ | |
| 343 | public void save() { | |
| 344 | try { | |
| 345 | final var config = new XMLConfiguration(); | |
| 346 | ||
| 347 | // The root config key can only be set for an empty configuration file. | |
| 348 | config.setRootElementName( APP_TITLE_LOWERCASE ); | |
| 349 | ||
| 350 | saveValues( ( key, property ) -> | |
| 351 | config.setProperty( key.toString(), marshall( property ) ) | |
| 352 | ); | |
| 353 | ||
| 354 | saveSets( | |
| 355 | ( key, set ) -> { | |
| 356 | final var keyName = key.toString(); | |
| 357 | set.forEach( ( value ) -> config.addProperty( keyName, value ) ); | |
| 358 | } | |
| 359 | ); | |
| 360 | new FileHandler( config ).save( FILE_PREFERENCES ); | |
| 361 | } catch( final Exception ex ) { | |
| 362 | clue( ex ); | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | /** | |
| 367 | * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file. | |
| 368 | * If not found, this will fall back to an empty configuration file, leaving | |
| 369 | * the application to fill in default values. | |
| 370 | */ | |
| 371 | private void load() { | |
| 372 | try { | |
| 373 | final var config = new Configurations().xml( FILE_PREFERENCES ); | |
| 374 | ||
| 375 | loadValueKeys( ( key ) -> { | |
| 376 | final var configValue = config.getProperty( key.toString() ); | |
| 377 | final var propertyValue = valuesProperty( key ); | |
| 378 | propertyValue.setValue( unmarshall( propertyValue, configValue ) ); | |
| 379 | } ); | |
| 380 | ||
| 381 | loadSetKeys( ( key ) -> { | |
| 382 | final var configSet = | |
| 383 | new LinkedHashSet<>( config.getList( key.toString() ) ); | |
| 384 | final var propertySet = setsProperty( key ); | |
| 385 | propertySet.setValue( observableSet( configSet ) ); | |
| 386 | } ); | |
| 387 | } catch( final Exception ex ) { | |
| 388 | clue( ex ); | |
| 389 | } | |
| 390 | } | |
| 391 | ||
| 392 | private Object unmarshall( | |
| 393 | final Property<?> property, final Object configValue ) { | |
| 394 | return UNMARSHALL | |
| 395 | .getOrDefault( property.getClass(), ( value ) -> value ) | |
| 396 | .apply( configValue.toString() ); | |
| 397 | } | |
| 398 | ||
| 399 | private Object marshall( final Property<?> property ) { | |
| 400 | return MARSHALL | |
| 401 | .getOrDefault( property.getClass(), ( v ) -> property.getValue() ) | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import com.keenwrite.Constants; | |
| 5 | import com.keenwrite.sigils.Tokens; | |
| 6 | import javafx.application.Platform; | |
| 7 | import javafx.beans.property.*; | |
| 8 | import javafx.collections.ObservableList; | |
| 9 | import org.apache.commons.configuration2.XMLConfiguration; | |
| 10 | import org.apache.commons.configuration2.builder.fluent.Configurations; | |
| 11 | import org.apache.commons.configuration2.io.FileHandler; | |
| 12 | ||
| 13 | import java.io.File; | |
| 14 | import java.util.*; | |
| 15 | import java.util.function.BiConsumer; | |
| 16 | import java.util.function.BooleanSupplier; | |
| 17 | import java.util.function.Consumer; | |
| 18 | import java.util.function.Function; | |
| 19 | ||
| 20 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; | |
| 21 | import static com.keenwrite.Constants.*; | |
| 22 | import static com.keenwrite.Launcher.getVersion; | |
| 23 | import static com.keenwrite.StatusNotifier.clue; | |
| 24 | import static com.keenwrite.preferences.Key.key; | |
| 25 | import static java.util.Map.entry; | |
| 26 | import static javafx.application.Platform.runLater; | |
| 27 | import static javafx.collections.FXCollections.observableArrayList; | |
| 28 | import static javafx.collections.FXCollections.observableSet; | |
| 29 | ||
| 30 | /** | |
| 31 | * Responsible for defining behaviours for separate projects. A workspace has | |
| 32 | * the ability to save and restore a session, including the window dimensions, | |
| 33 | * tab setup, files, and user preferences. | |
| 34 | * <p> | |
| 35 | * The configuration must support hierarchical (nested) configuration nodes | |
| 36 | * to persist the user interface state. Although possible with a flat | |
| 37 | * configuration file, it's not nearly as simple or elegant. | |
| 38 | * </p> | |
| 39 | * <p> | |
| 40 | * Neither JSON nor HOCON support schema validation and versioning, which makes | |
| 41 | * XML the more suitable configuration file format. Schema validation and | |
| 42 | * versioning provide future-proofing and ease of reading and upgrading previous | |
| 43 | * versions of the configuration file. | |
| 44 | * </p> | |
| 45 | * <p> | |
| 46 | * Persistent preferences may be set directly by the user or indirectly by | |
| 47 | * the act of using the application. | |
| 48 | * </p> | |
| 49 | * <p> | |
| 50 | * Note the following definitions: | |
| 51 | * </p> | |
| 52 | * <dl> | |
| 53 | * <dt>File</dt> | |
| 54 | * <dd>References a file name (no path), path, or directory.</dd> | |
| 55 | * <dt>Path</dt> | |
| 56 | * <dd>Fully qualified file name, which includes all parent directories.</dd> | |
| 57 | * <dt>Dir</dt> | |
| 58 | * <dd>Directory without a file name ({@link File#isDirectory()} is true) | |
| 59 | * .</dd> | |
| 60 | * </dl> | |
| 61 | */ | |
| 62 | public class Workspace { | |
| 63 | private static final Key KEY_ROOT = key( "workspace" ); | |
| 64 | ||
| 65 | public static final Key KEY_META = key( KEY_ROOT, "meta" ); | |
| 66 | public static final Key KEY_META_NAME = key( KEY_META, "name" ); | |
| 67 | public static final Key KEY_META_VERSION = key( KEY_META, "version" ); | |
| 68 | ||
| 69 | public static final Key KEY_R = key( KEY_ROOT, "r" ); | |
| 70 | public static final Key KEY_R_SCRIPT = key( KEY_R, "script" ); | |
| 71 | public static final Key KEY_R_DIR = key( KEY_R, "dir" ); | |
| 72 | public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" ); | |
| 73 | public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" ); | |
| 74 | public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" ); | |
| 75 | ||
| 76 | public static final Key KEY_IMAGES = key( KEY_ROOT, "images" ); | |
| 77 | public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" ); | |
| 78 | public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" ); | |
| 79 | ||
| 80 | public static final Key KEY_DEF = key( KEY_ROOT, "definition" ); | |
| 81 | public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" ); | |
| 82 | public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" ); | |
| 83 | public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" ); | |
| 84 | public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" ); | |
| 85 | ||
| 86 | //@formatter:off | |
| 87 | public static final Key KEY_UI = key( KEY_ROOT, "ui" ); | |
| 88 | ||
| 89 | public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" ); | |
| 90 | public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" ); | |
| 91 | public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" ); | |
| 92 | public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" ); | |
| 93 | ||
| 94 | public static final Key KEY_UI_FILES = key( KEY_UI, "files" ); | |
| 95 | public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" ); | |
| 96 | ||
| 97 | public static final Key KEY_UI_FONT = key( KEY_UI, "font" ); | |
| 98 | public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" ); | |
| 99 | public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" ); | |
| 100 | public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" ); | |
| 101 | public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" ); | |
| 102 | public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" ); | |
| 103 | public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" ); | |
| 104 | public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" ); | |
| 105 | public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" ); | |
| 106 | public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" ); | |
| 107 | ||
| 108 | public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" ); | |
| 109 | public static final Key KEY_LANG_LOCALE = key( KEY_LANGUAGE, "locale" ); | |
| 110 | ||
| 111 | public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" ); | |
| 112 | public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" ); | |
| 113 | public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" ); | |
| 114 | public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" ); | |
| 115 | public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" ); | |
| 116 | public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" ); | |
| 117 | public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" ); | |
| 118 | ||
| 119 | private final Map<Key, Property<?>> VALUES = Map.ofEntries( | |
| 120 | entry( KEY_META_VERSION, asStringProperty( getVersion() ) ), | |
| 121 | entry( KEY_META_NAME, asStringProperty( "default" ) ), | |
| 122 | ||
| 123 | entry( KEY_R_SCRIPT, asStringProperty( "" ) ), | |
| 124 | entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 125 | entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ), | |
| 126 | entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ), | |
| 127 | ||
| 128 | entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 129 | entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ), | |
| 130 | ||
| 131 | entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ), | |
| 132 | entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ), | |
| 133 | entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ), | |
| 134 | ||
| 135 | entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 136 | entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ), | |
| 137 | entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ), | |
| 138 | ||
| 139 | entry( KEY_LANG_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ), | |
| 140 | entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ), | |
| 141 | entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ), | |
| 142 | entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ), | |
| 143 | entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ), | |
| 144 | entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ), | |
| 145 | entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ), | |
| 146 | ||
| 147 | entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ), | |
| 148 | entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ), | |
| 149 | entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ), | |
| 150 | entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ), | |
| 151 | entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ), | |
| 152 | entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ) | |
| 153 | ); | |
| 154 | //@formatter:on | |
| 155 | ||
| 156 | private StringProperty asStringProperty( final String defaultValue ) { | |
| 157 | return new SimpleStringProperty( defaultValue ); | |
| 158 | } | |
| 159 | ||
| 160 | private DoubleProperty asDoubleProperty( final double defaultValue ) { | |
| 161 | return new SimpleDoubleProperty( defaultValue ); | |
| 162 | } | |
| 163 | ||
| 164 | private BooleanProperty asBooleanProperty() { | |
| 165 | return new SimpleBooleanProperty(); | |
| 166 | } | |
| 167 | ||
| 168 | private FileProperty asFileProperty( final File defaultValue ) { | |
| 169 | return new FileProperty( defaultValue ); | |
| 170 | } | |
| 171 | ||
| 172 | @SuppressWarnings( "SameParameterValue" ) | |
| 173 | private LocaleProperty asLocaleProperty( final Locale defaultValue ) { | |
| 174 | return new LocaleProperty( defaultValue ); | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Helps instantiate {@link Property} instances for XML configuration items. | |
| 179 | */ | |
| 180 | private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | |
| 181 | Map.of( | |
| 182 | LocaleProperty.class, LocaleProperty::parseLocale, | |
| 183 | SimpleBooleanProperty.class, Boolean::parseBoolean, | |
| 184 | SimpleDoubleProperty.class, Double::parseDouble, | |
| 185 | SimpleFloatProperty.class, Float::parseFloat, | |
| 186 | FileProperty.class, File::new | |
| 187 | ); | |
| 188 | ||
| 189 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 190 | Map.of( | |
| 191 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 192 | ); | |
| 193 | ||
| 194 | private final Map<Key, SetProperty<?>> SETS = Map.ofEntries( | |
| 195 | entry( | |
| 196 | KEY_UI_FILES_PATH, | |
| 197 | new SimpleSetProperty<>( observableSet( new HashSet<>() ) ) | |
| 198 | ) | |
| 199 | ); | |
| 200 | ||
| 201 | /** | |
| 202 | * Creates a new {@link Workspace} that will attempt to load a configuration | |
| 203 | * file. If the configuration file cannot be loaded, the workspace settings | |
| 204 | * will return default values. This allows unit tests to provide an instance | |
| 205 | * of {@link Workspace} when necessary without encountering failures. | |
| 206 | */ | |
| 207 | public Workspace() { | |
| 208 | load(); | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Creates an instance of {@link ObservableList} that is based on a | |
| 213 | * modifiable observable array list for the given items. | |
| 214 | * | |
| 215 | * @param items The items to wrap in an observable list. | |
| 216 | * @param <E> The type of items to add to the list. | |
| 217 | * @return An observable property that can have its contents modified. | |
| 218 | */ | |
| 219 | public static <E> ObservableList<E> listProperty( final Set<E> items ) { | |
| 220 | return new SimpleListProperty<>( observableArrayList( items ) ); | |
| 221 | } | |
| 222 | ||
| 223 | /** | |
| 224 | * Returns a value that represents a setting in the application that the user | |
| 225 | * may configure, either directly or indirectly. | |
| 226 | * | |
| 227 | * @param key The reference to the users' preference stored in deference | |
| 228 | * of app reëntrance. | |
| 229 | * @return An observable property to be persisted. | |
| 230 | */ | |
| 231 | @SuppressWarnings( "unchecked" ) | |
| 232 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 233 | // The type that goes into the map must come out. | |
| 234 | return (U) VALUES.get( key ); | |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * Returns a list of values that represent a setting in the application that | |
| 239 | * the user may configure, either directly or indirectly. The property | |
| 240 | * returned is backed by a mutable {@link Set}. | |
| 241 | * | |
| 242 | * @param key The {@link Key} associated with a preference value. | |
| 243 | * @return An observable property to be persisted. | |
| 244 | */ | |
| 245 | @SuppressWarnings( "unchecked" ) | |
| 246 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 247 | // The type that goes into the map must come out. | |
| 248 | return (SetProperty<T>) SETS.get( key ); | |
| 249 | } | |
| 250 | ||
| 251 | /** | |
| 252 | * Returns the {@link Boolean} preference value associated with the given | |
| 253 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 254 | * associated with a value that matches the return type. | |
| 255 | * | |
| 256 | * @param key The {@link Key} associated with a preference value. | |
| 257 | * @return The value associated with the given {@link Key}. | |
| 258 | */ | |
| 259 | public boolean toBoolean( final Key key ) { | |
| 260 | return (Boolean) valuesProperty( key ).getValue(); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Returns the {@link Double} preference value associated with the given | |
| 265 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 266 | * associated with a value that matches the return type. | |
| 267 | * | |
| 268 | * @param key The {@link Key} associated with a preference value. | |
| 269 | * @return The value associated with the given {@link Key}. | |
| 270 | */ | |
| 271 | public double toDouble( final Key key ) { | |
| 272 | return (Double) valuesProperty( key ).getValue(); | |
| 273 | } | |
| 274 | ||
| 275 | public File toFile( final Key key ) { | |
| 276 | return fileProperty( key ).get(); | |
| 277 | } | |
| 278 | ||
| 279 | public String toString( final Key key ) { | |
| 280 | return stringProperty( key ).get(); | |
| 281 | } | |
| 282 | ||
| 283 | public Tokens toTokens( final Key began, final Key ended ) { | |
| 284 | return new Tokens( stringProperty( began ), stringProperty( ended ) ); | |
| 285 | } | |
| 286 | ||
| 287 | @SuppressWarnings( "SameParameterValue" ) | |
| 288 | public DoubleProperty doubleProperty( final Key key ) { | |
| 289 | return valuesProperty( key ); | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Returns the {@link File} {@link Property} associated with the given | |
| 294 | * {@link Key} from the internal list of preference values. The caller | |
| 295 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 296 | * {@link Property}. | |
| 297 | * | |
| 298 | * @param key The {@link Key} associated with a preference value. | |
| 299 | * @return The value associated with the given {@link Key}. | |
| 300 | */ | |
| 301 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 302 | return valuesProperty( key ); | |
| 303 | } | |
| 304 | ||
| 305 | public LocaleProperty localeProperty( final Key key ) { | |
| 306 | return valuesProperty( key ); | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * Returns the language locale setting for the {@link #KEY_LANG_LOCALE} key. | |
| 311 | * | |
| 312 | * @return The user's current locale setting. | |
| 313 | */ | |
| 314 | public Locale getLocale() { | |
| 315 | return localeProperty( KEY_LANG_LOCALE ).toLocale(); | |
| 316 | } | |
| 317 | ||
| 318 | public StringProperty stringProperty( final Key key ) { | |
| 319 | return valuesProperty( key ); | |
| 320 | } | |
| 321 | ||
| 322 | public void loadValueKeys( final Consumer<Key> consumer ) { | |
| 323 | VALUES.keySet().forEach( consumer ); | |
| 324 | } | |
| 325 | ||
| 326 | public void loadSetKeys( final Consumer<Key> consumer ) { | |
| 327 | SETS.keySet().forEach( consumer ); | |
| 328 | } | |
| 329 | ||
| 330 | /** | |
| 331 | * Calls the given consumer for all single-value keys. For lists, see | |
| 332 | * {@link #saveSets(BiConsumer)}. | |
| 333 | * | |
| 334 | * @param consumer Called to accept each preference key value. | |
| 335 | */ | |
| 336 | public void saveValues( final BiConsumer<Key, Property<?>> consumer ) { | |
| 337 | VALUES.forEach( consumer ); | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Calls the given consumer for all multi-value keys. For single items, see | |
| 342 | * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating | |
| 343 | * over the list of items retrieved through this method. | |
| 344 | * | |
| 345 | * @param consumer Called to accept each preference key list. | |
| 346 | */ | |
| 347 | public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) { | |
| 348 | SETS.forEach( consumer ); | |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | |
| 353 | * providing a value of {@code true} for the {@link BooleanSupplier} to | |
| 354 | * indicate the property changes always take effect. | |
| 355 | * | |
| 356 | * @param key The value to bind to the internal key property. | |
| 357 | * @param property The external property value that sets the internal value. | |
| 358 | */ | |
| 359 | public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | |
| 360 | listen( key, property, () -> true ); | |
| 361 | } | |
| 362 | ||
| 363 | /** | |
| 364 | * Binds a read-only property to a value in the preferences. This allows | |
| 365 | * user interface properties to change and the preferences will be | |
| 366 | * synchronized automatically. | |
| 367 | * <p> | |
| 368 | * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | |
| 369 | * application window states are finished before assessing whether property | |
| 370 | * changes should be applied. Without this, exiting the application while the | |
| 371 | * window is maximized would persist the window's maximum dimensions, | |
| 372 | * preventing restoration to its prior, non-maximum size. | |
| 373 | * </p> | |
| 374 | * | |
| 375 | * @param key The value to bind to the internal key property. | |
| 376 | * @param property The external property value that sets the internal value. | |
| 377 | * @param enabled Indicates whether property changes should be applied. | |
| 378 | */ | |
| 379 | public <T> void listen( | |
| 380 | final Key key, | |
| 381 | final ReadOnlyProperty<T> property, | |
| 382 | final BooleanSupplier enabled ) { | |
| 383 | property.addListener( | |
| 384 | ( c, o, n ) -> runLater( () -> { | |
| 385 | if( enabled.getAsBoolean() ) { | |
| 386 | valuesProperty( key ).setValue( n ); | |
| 387 | } | |
| 388 | } ) | |
| 389 | ); | |
| 390 | } | |
| 391 | ||
| 392 | /** | |
| 393 | * Saves the current workspace. | |
| 394 | */ | |
| 395 | public void save() { | |
| 396 | try { | |
| 397 | final var config = new XMLConfiguration(); | |
| 398 | ||
| 399 | // The root config key can only be set for an empty configuration file. | |
| 400 | config.setRootElementName( APP_TITLE_LOWERCASE ); | |
| 401 | valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | |
| 402 | ||
| 403 | saveValues( ( key, property ) -> | |
| 404 | config.setProperty( key.toString(), marshall( property ) ) | |
| 405 | ); | |
| 406 | ||
| 407 | saveSets( ( key, set ) -> { | |
| 408 | final var keyName = key.toString(); | |
| 409 | set.forEach( ( value ) -> config.addProperty( keyName, value ) ); | |
| 410 | } ); | |
| 411 | new FileHandler( config ).save( FILE_PREFERENCES ); | |
| 412 | } catch( final Exception ex ) { | |
| 413 | clue( ex ); | |
| 414 | } | |
| 415 | } | |
| 416 | ||
| 417 | /** | |
| 418 | * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file. | |
| 419 | * If not found, this will fall back to an empty configuration file, leaving | |
| 420 | * the application to fill in default values. | |
| 421 | */ | |
| 422 | private void load() { | |
| 423 | try { | |
| 424 | final var config = new Configurations().xml( FILE_PREFERENCES ); | |
| 425 | ||
| 426 | loadValueKeys( ( key ) -> { | |
| 427 | final var configValue = config.getProperty( key.toString() ); | |
| 428 | ||
| 429 | // Allow other properties to load, even if any are missing. | |
| 430 | if( configValue != null ) { | |
| 431 | final var propertyValue = valuesProperty( key ); | |
| 432 | propertyValue.setValue( unmarshall( propertyValue, configValue ) ); | |
| 433 | } | |
| 434 | } ); | |
| 435 | ||
| 436 | loadSetKeys( ( key ) -> { | |
| 437 | final var configSet = | |
| 438 | new LinkedHashSet<>( config.getList( key.toString() ) ); | |
| 439 | final var propertySet = setsProperty( key ); | |
| 440 | propertySet.setValue( observableSet( configSet ) ); | |
| 441 | } ); | |
| 442 | } catch( final Exception ex ) { | |
| 443 | clue( ex ); | |
| 444 | } | |
| 445 | } | |
| 446 | ||
| 447 | private Object unmarshall( | |
| 448 | final Property<?> property, final Object configValue ) { | |
| 449 | return UNMARSHALL | |
| 450 | .getOrDefault( property.getClass(), ( value ) -> value ) | |
| 451 | .apply( configValue.toString() ); | |
| 452 | } | |
| 453 | ||
| 454 | private Object marshall( final Property<?> property ) { | |
| 455 | return MARSHALL | |
| 456 | .getOrDefault( property.getClass(), ( __ ) -> property.getValue() ) | |
| 402 | 457 | .apply( property.getValue().toString() ); |
| 403 | 458 | } |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 |
| 1 | 1 | /* Copyright 2006 Patrick Wright |
| 2 | 2 | * Copyright 2007 Wisconsin Court System |
| 3 | * Copyright 2020 White Magic Software, Ltd. | |
| 3 | * Copyright 2020-2021 White Magic Software, Ltd. | |
| 4 | 4 | * |
| 5 | 5 | * This program is free software; you can redistribute it and/or |
| ... | ||
| 34 | 34 | import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE; |
| 35 | 35 | import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE_SRC; |
| 36 | import static com.keenwrite.processors.markdown.tex.TexNode.HTML_TEX; | |
| 36 | import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX; | |
| 37 | 37 | import static java.util.Arrays.asList; |
| 38 | 38 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| ... | ||
| 14 | 14 | import java.util.Map; |
| 15 | 15 | |
| 16 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 16 | import static com.keenwrite.StatusNotifier.clue; | |
| 17 | 17 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; |
| 18 | 18 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| ... | ||
| 18 | 18 | import java.net.URI; |
| 19 | 19 | |
| 20 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 21 | 21 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| 22 | 22 | import static java.awt.Desktop.Action.BROWSE; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.preferences.LocaleProperty; |
| 5 | 5 | import com.keenwrite.preferences.Workspace; |
| 6 | 6 | import javafx.beans.property.DoubleProperty; |
| 7 | import javafx.beans.property.StringProperty; | |
| 7 | 8 | import javafx.embed.swing.SwingNode; |
| 8 | 9 | import org.xhtmlrenderer.render.Box; |
| ... | ||
| 17 | 18 | import static com.keenwrite.Constants.*; |
| 18 | 19 | import static com.keenwrite.Messages.get; |
| 19 | import static com.keenwrite.preferences.Workspace.KEY_UI_FONT_LOCALE; | |
| 20 | import static com.keenwrite.preferences.Workspace.KEY_UI_FONT_PREVIEW_SIZE; | |
| 20 | import static com.keenwrite.preferences.Workspace.*; | |
| 21 | 21 | import static java.lang.Math.max; |
| 22 | 22 | import static java.lang.String.format; |
| ... | ||
| 41 | 41 | /** |
| 42 | 42 | * Render CSS using points (pt) not pixels (px) to reduce the chance of |
| 43 | * poor rendering. | |
| 43 | * poor rendering. The {@link #head()} method fills out the placeholders. | |
| 44 | 44 | */ |
| 45 | 45 | private static final String HTML_HEAD = |
| 46 | 46 | """ |
| 47 | 47 | <!DOCTYPE html> |
| 48 | 48 | <html lang='%s'><head><title> </title><meta charset='utf-8'> |
| 49 | 49 | <link rel='stylesheet' href='%s'> |
| 50 | 50 | <link rel='stylesheet' href='%s'> |
| 51 | <style>body{font-size: %spt;}</style> | |
| 51 | <style>body{font-family:'%s';font-size: %spt;}</style> | |
| 52 | 52 | <base href='%s'> |
| 53 | 53 | </head><body> |
| ... | ||
| 101 | 101 | } ); |
| 102 | 102 | |
| 103 | fontNameProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 103 | 104 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); |
| 104 | 105 | } ); |
| ... | ||
| 145 | 146 | HTML_STYLE_PREVIEW, |
| 146 | 147 | mLocaleUrl, |
| 148 | getFontName(), | |
| 147 | 149 | getFontSize(), |
| 148 | 150 | mBaseUriPath |
| ... | ||
| 277 | 279 | |
| 278 | 280 | private LocaleProperty localeProperty() { |
| 279 | return mWorkspace.localeProperty( KEY_UI_FONT_LOCALE ); | |
| 281 | return mWorkspace.localeProperty( KEY_LANG_LOCALE ); | |
| 282 | } | |
| 283 | ||
| 284 | private String getFontName() { | |
| 285 | return fontNameProperty().get(); | |
| 286 | } | |
| 287 | ||
| 288 | private StringProperty fontNameProperty() { | |
| 289 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 280 | 290 | } |
| 281 | 291 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 4 | 4 | import com.whitemagicsoftware.tex.*; |
| 5 | 5 | import com.whitemagicsoftware.tex.graphics.SvgDomGraphics2D; |
| 6 | 6 | import org.w3c.dom.Document; |
| 7 | 7 | |
| 8 | 8 | import java.util.function.Supplier; |
| 9 | 9 | |
| 10 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 10 | import static com.keenwrite.StatusNotifier.clue; | |
| 11 | 11 | |
| 12 | 12 | /** |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| ... | ||
| 24 | 24 | import java.text.NumberFormat; |
| 25 | 25 | |
| 26 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 26 | import static com.keenwrite.StatusNotifier.clue; | |
| 27 | 27 | import static com.keenwrite.preview.RenderingSettings.RENDERING_HINTS; |
| 28 | 28 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| ... | ||
| 16 | 16 | import java.nio.file.Paths; |
| 17 | 17 | |
| 18 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 18 | import static com.keenwrite.StatusNotifier.clue; | |
| 19 | 19 | import static com.keenwrite.io.MediaType.*; |
| 20 | 20 | import static com.keenwrite.preview.MathRenderer.MATH_RENDERER; |
| 21 | 21 | import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER; |
| 22 | 22 | import static com.keenwrite.preview.SvgRasterizer.rasterize; |
| 23 | import static com.keenwrite.processors.markdown.tex.TexNode.HTML_TEX; | |
| 23 | import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX; | |
| 24 | 24 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| 25 | 25 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | 5 | * Responsible for transforming a string into itself. This is used at the |
| 6 | 6 | * end of a processing chain when no more processing is required. |
| 7 | 7 | */ |
| 8 | 8 | public class IdentityProcessor extends ExecutorProcessor<String> { |
| 9 | public static final IdentityProcessor INSTANCE = new IdentityProcessor(); | |
| 9 | public static final IdentityProcessor IDENTITY = new IdentityProcessor(); | |
| 10 | 10 | |
| 11 | 11 | /** |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.preferences.Workspace; |
| 5 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 6 | import com.vladsch.flexmark.ast.Paragraph; | |
| 7 | import com.vladsch.flexmark.ast.Text; | |
| 5 | import com.keenwrite.processors.markdown.extensions.r.ROutputProcessor; | |
| 6 | import com.keenwrite.util.BoundedCache; | |
| 8 | 7 | import javafx.beans.property.Property; |
| 9 | 8 | |
| 10 | 9 | import javax.script.ScriptEngine; |
| 11 | 10 | import javax.script.ScriptEngineManager; |
| 12 | 11 | import java.io.File; |
| 13 | 12 | import java.nio.file.Path; |
| 14 | import java.util.LinkedHashMap; | |
| 15 | 13 | import java.util.Map; |
| 16 | 14 | import java.util.concurrent.atomic.AtomicBoolean; |
| 17 | 15 | |
| 18 | 16 | import static com.keenwrite.Constants.STATUS_PARSE_ERROR; |
| 19 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 17 | import static com.keenwrite.Messages.get; | |
| 18 | import static com.keenwrite.StatusNotifier.clue; | |
| 20 | 19 | import static com.keenwrite.preferences.Workspace.*; |
| 21 | 20 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; |
| 22 | 21 | import static com.keenwrite.sigils.RSigilOperator.PREFIX; |
| 23 | 22 | import static com.keenwrite.sigils.RSigilOperator.SUFFIX; |
| 23 | import static java.lang.Math.max; | |
| 24 | 24 | import static java.lang.Math.min; |
| 25 | 25 | |
| 26 | 26 | /** |
| 27 | 27 | * Transforms a document containing R statements into Markdown. |
| 28 | 28 | */ |
| 29 | 29 | public final class InlineRProcessor extends DefinitionProcessor { |
| 30 | /** | |
| 31 | * Constrain memory when typing new R expressions into the document. | |
| 32 | */ | |
| 33 | private static final int MAX_CACHED_R_STATEMENTS = 512; | |
| 34 | ||
| 35 | private final MarkdownProcessor mMarkdownProcessor; | |
| 30 | private final Processor<String> mPostProcessor = new ROutputProcessor(); | |
| 36 | 31 | |
| 37 | 32 | /** |
| 38 | * Where to put document inline evaluated R expressions. | |
| 33 | * Where to put document inline evaluated R expressions, constrained to | |
| 34 | * avoid running out of memory. | |
| 39 | 35 | */ |
| 40 | private final Map<String, String> mEvalCache = new LinkedHashMap<>() { | |
| 41 | @Override | |
| 42 | protected boolean removeEldestEntry( | |
| 43 | final Map.Entry<String, String> eldest ) { | |
| 44 | return size() > MAX_CACHED_R_STATEMENTS; | |
| 45 | } | |
| 46 | }; | |
| 36 | private final Map<String, String> mEvalCache = | |
| 37 | new BoundedCache<>( 512 ); | |
| 47 | 38 | |
| 48 | 39 | private static final ScriptEngine ENGINE = |
| ... | ||
| 67 | 58 | |
| 68 | 59 | mWorkspace = context.getWorkspace(); |
| 69 | mMarkdownProcessor = MarkdownProcessor.create( context ); | |
| 70 | 60 | |
| 71 | 61 | bootstrapScriptProperty().addListener( |
| ... | ||
| 92 | 82 | * any existing R functionality will not be overwritten if this method is |
| 93 | 83 | * called multiple times. |
| 84 | * | |
| 85 | * @return {@code true} if initialization completed and all variables were | |
| 86 | * replaced; {@code false} if any variables remain. | |
| 94 | 87 | */ |
| 95 | private void init() { | |
| 88 | public boolean init() { | |
| 96 | 89 | final var bootstrap = getBootstrapScript(); |
| 97 | 90 | |
| ... | ||
| 105 | 98 | map.put( defBegan + "application.r.working.directory" + defEnded, dir ); |
| 106 | 99 | |
| 107 | eval( replace( bootstrap, map ) ); | |
| 100 | final var replaced = replace( bootstrap, map ); | |
| 101 | final var bIndex = replaced.indexOf( defBegan ); | |
| 102 | ||
| 103 | // | |
| 104 | if( bIndex >= 0 ) { | |
| 105 | var eIndex = replaced.indexOf( defEnded ); | |
| 106 | eIndex = (eIndex == -1) ? replaced.length() - 1 : max( bIndex, eIndex ); | |
| 107 | ||
| 108 | final var def = replaced.substring( bIndex, eIndex ); | |
| 109 | clue( "Main.status.error.bootstrap.eval", def ); | |
| 110 | ||
| 111 | return false; | |
| 112 | } | |
| 113 | else { | |
| 114 | eval( replaced ); | |
| 115 | } | |
| 108 | 116 | } |
| 117 | ||
| 118 | return true; | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * Empties the cache. | |
| 123 | */ | |
| 124 | public void clear() { | |
| 125 | mEvalCache.clear(); | |
| 109 | 126 | } |
| 110 | 127 | |
| ... | ||
| 162 | 179 | if( currIndex > 1 ) { |
| 163 | 180 | // Extract the inline R statement to be evaluated. |
| 164 | final String r = text.substring( prevIndex, currIndex ); | |
| 181 | final var r = text.substring( prevIndex, currIndex ); | |
| 165 | 182 | |
| 166 | 183 | // Pass the R statement into the R engine for evaluation. |
| 167 | 184 | try { |
| 168 | final var result = evalCached( r ); | |
| 169 | ||
| 170 | 185 | // Append the string representation of the result into the text. |
| 171 | sb.append( result ); | |
| 186 | sb.append( evalCached( r ) ); | |
| 172 | 187 | } catch( final Exception ex ) { |
| 173 | 188 | // Inform the user that there was a problem. |
| ... | ||
| 199 | 214 | */ |
| 200 | 215 | private String evalCached( final String r ) { |
| 201 | return mEvalCache.computeIfAbsent( r, v -> evalHtml( r ) ); | |
| 216 | return mEvalCache.computeIfAbsent( r, __ -> evalHtml( r ) ); | |
| 202 | 217 | } |
| 203 | 218 | |
| ... | ||
| 212 | 227 | */ |
| 213 | 228 | private String evalHtml( final String r ) { |
| 214 | final var markdown = eval( r ); | |
| 215 | var node = mMarkdownProcessor.toNode( markdown ).getFirstChild(); | |
| 216 | ||
| 217 | if( node != null && node.isOrDescendantOfType( Paragraph.class ) ) { | |
| 218 | node = new Text( node.getChars() ); | |
| 219 | } | |
| 220 | ||
| 221 | // Trimming prevents displaced commas and unwanted newlines. | |
| 222 | return mMarkdownProcessor.toHtml( node ).trim(); | |
| 229 | return mPostProcessor.apply( eval( r ) ); | |
| 223 | 230 | } |
| 224 | 231 | |
| ... | ||
| 233 | 240 | return ENGINE.eval( r ).toString(); |
| 234 | 241 | } catch( final Exception ex ) { |
| 235 | final var expr = r.substring( 0, min( r.length(), 30 ) ); | |
| 236 | clue( "Main.status.error.r", expr, ex.getMessage() ); | |
| 242 | final var expr = r.substring( 0, min( r.length(), 50 ) ); | |
| 243 | clue( get( "Main.status.error.r", expr, ex.getMessage() ), ex ); | |
| 237 | 244 | return ""; |
| 238 | 245 | } |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.Constants; |
| 5 | 5 | import com.keenwrite.ExportFormat; |
| 6 | 6 | import com.keenwrite.io.FileType; |
| 7 | 7 | import com.keenwrite.preferences.Workspace; |
| 8 | 8 | import com.keenwrite.preview.HtmlPreview; |
| 9 | import com.keenwrite.processors.markdown.Caret; | |
| 9 | import com.keenwrite.Caret; | |
| 10 | 10 | |
| 11 | 11 | import java.nio.file.Path; |
| ... | ||
| 40 | 40 | */ |
| 41 | 41 | public ProcessorContext( |
| 42 | final HtmlPreview htmlPreview, | |
| 43 | final Map<String, String> resolvedMap, | |
| 44 | final Path path, | |
| 45 | final Caret caret, | |
| 46 | final ExportFormat exportFormat, | |
| 47 | final Workspace workspace ) { | |
| 42 | final HtmlPreview htmlPreview, | |
| 43 | final Map<String, String> resolvedMap, | |
| 44 | final Path path, | |
| 45 | final Caret caret, | |
| 46 | final ExportFormat exportFormat, | |
| 47 | final Workspace workspace ) { | |
| 48 | 48 | assert htmlPreview != null; |
| 49 | 49 | assert resolvedMap != null; |
| ... | ||
| 61 | 61 | } |
| 62 | 62 | |
| 63 | @SuppressWarnings("SameParameterValue") | |
| 63 | @SuppressWarnings( "SameParameterValue" ) | |
| 64 | 64 | boolean isExportFormat( final ExportFormat format ) { |
| 65 | 65 | return mExportFormat == format; |
| ... | ||
| 105 | 105 | * default user directory if the base path cannot be determined. |
| 106 | 106 | */ |
| 107 | public Path getBasePath() { | |
| 107 | public Path getBaseDir() { | |
| 108 | 108 | final var path = getPath().toAbsolutePath().getParent(); |
| 109 | 109 | return path == null ? DEFAULT_DIRECTORY : path; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| ... | ||
| 44 | 44 | |
| 45 | 45 | final var processor = switch( context.getFileType() ) { |
| 46 | case RMARKDOWN -> createRProcessor( successor ); | |
| 47 | case SOURCE -> createMarkdownProcessor( successor ); | |
| 46 | //case RMARKDOWN -> createRProcessor( successor ); | |
| 47 | case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor ); | |
| 48 | 48 | case RXML -> createRXMLProcessor( successor ); |
| 49 | 49 | case XML -> createXMLProcessor( successor ); |
| ... | ||
| 73 | 73 | */ |
| 74 | 74 | private Processor<String> createIdentityProcessor() { |
| 75 | return IdentityProcessor.INSTANCE; | |
| 75 | return IdentityProcessor.IDENTITY; | |
| 76 | 76 | } |
| 77 | 77 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| ... | ||
| 34 | 34 | @Override |
| 35 | 35 | protected Map<String, String> getDefinitions() { |
| 36 | return toR( super.getDefinitions() ); | |
| 36 | return entoken( super.getDefinitions() ); | |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | /** |
| 40 | 40 | * Converts the given map from regular variables to R variables. |
| 41 | 41 | * |
| 42 | 42 | * @param map Map of variable names to values. |
| 43 | 43 | * @return Map of R variables. |
| 44 | 44 | */ |
| 45 | private Map<String, String> toR( final Map<String, String> map ) { | |
| 45 | private Map<String, String> entoken( final Map<String, String> map ) { | |
| 46 | 46 | final var rMap = new HashMap<String, String>( map.size() ); |
| 47 | 47 | |
| 48 | 48 | for( final var entry : map.entrySet() ) { |
| 49 | 49 | final var key = entry.getKey(); |
| 50 | rMap.put( mSigilOperator.entoken( key ), toRValue( map.get( key ) ) ); | |
| 50 | rMap.put( mSigilOperator.entoken( key ), escape( map.get( key ) ) ); | |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | return rMap; |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | private String toRValue( final String value ) { | |
| 56 | private String escape( final String value ) { | |
| 57 | 57 | return '\'' + escape( value, '\'', "\\'" ) + '\''; |
| 58 | 58 | } |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| ... | ||
| 134 | 134 | * @param xsl The stylesheet to use for transforming XML documents. |
| 135 | 135 | * @return The edited XML document transformed into another format (usually |
| 136 | * markdown). | |
| 136 | * Markdown). | |
| 137 | 137 | * @throws TransformerConfigurationException Could not create the transformer. |
| 138 | 138 | */ |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown; | |
| 3 | ||
| 4 | import com.keenwrite.util.GenericBuilder; | |
| 5 | import javafx.beans.value.ObservableValue; | |
| 6 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 7 | import org.fxmisc.richtext.model.Paragraph; | |
| 8 | import org.reactfx.collection.LiveList; | |
| 9 | ||
| 10 | import java.util.Collection; | |
| 11 | ||
| 12 | import static com.keenwrite.Constants.STATUS_BAR_LINE; | |
| 13 | import static com.keenwrite.Messages.get; | |
| 14 | ||
| 15 | /** | |
| 16 | * Represents the absolute, relative, and maximum position of the caret. The | |
| 17 | * caret position is a character offset into the text. | |
| 18 | */ | |
| 19 | public class Caret { | |
| 20 | ||
| 21 | public static GenericBuilder<Caret.Mutator, Caret> builder() { | |
| 22 | return GenericBuilder.of( Caret.Mutator::new, Caret::new ); | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * Used for building a new {@link Caret} instance. | |
| 27 | */ | |
| 28 | public static class Mutator { | |
| 29 | /** | |
| 30 | * Caret's current paragraph index (i.e., current caret line number). | |
| 31 | */ | |
| 32 | private ObservableValue<Integer> mParagraph; | |
| 33 | ||
| 34 | /** | |
| 35 | * Used to count the number of lines in the text editor document. | |
| 36 | */ | |
| 37 | private LiveList<Paragraph<Collection<String>, String, | |
| 38 | Collection<String>>> mParagraphs; | |
| 39 | ||
| 40 | /** | |
| 41 | * Caret offset into the full text, represented as a string index. | |
| 42 | */ | |
| 43 | private ObservableValue<Integer> mTextOffset; | |
| 44 | ||
| 45 | /** | |
| 46 | * Caret offset into the current paragraph, represented as a string index. | |
| 47 | */ | |
| 48 | private ObservableValue<Integer> mParaOffset; | |
| 49 | ||
| 50 | /** | |
| 51 | * Total number of characters in the document. | |
| 52 | */ | |
| 53 | private ObservableValue<Integer> mTextLength; | |
| 54 | ||
| 55 | /** | |
| 56 | * Configures this caret position using properties from the given editor. | |
| 57 | * | |
| 58 | * @param editor The text editor that has a caret with position properties. | |
| 59 | */ | |
| 60 | public void setEditor( final StyleClassedTextArea editor ) { | |
| 61 | mParagraph = editor.currentParagraphProperty(); | |
| 62 | mParagraphs = editor.getParagraphs(); | |
| 63 | mParaOffset = editor.caretColumnProperty(); | |
| 64 | mTextOffset = editor.caretPositionProperty(); | |
| 65 | mTextLength = editor.lengthProperty(); | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | private final Mutator mMutator; | |
| 70 | ||
| 71 | /** | |
| 72 | * Force using the builder pattern. | |
| 73 | */ | |
| 74 | private Caret( final Mutator mutator ) { | |
| 75 | assert mutator != null; | |
| 76 | ||
| 77 | mMutator = mutator; | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Allows observers to be notified when the value of the caret changes. | |
| 82 | * | |
| 83 | * @return An observer for the caret's document offset. | |
| 84 | */ | |
| 85 | public ObservableValue<Integer> textOffsetProperty() { | |
| 86 | return mMutator.mTextOffset; | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Answers whether the caret's offset into the text is between the given | |
| 91 | * offsets. | |
| 92 | * | |
| 93 | * @param began Starting value compared against the caret's text offset. | |
| 94 | * @param ended Ending value compared against the caret's text offset. | |
| 95 | * @return {@code true} when the caret's text offset is between the given | |
| 96 | * values, inclusively (for either value). | |
| 97 | */ | |
| 98 | public boolean isBetweenText( final int began, final int ended ) { | |
| 99 | final var offset = getTextOffset(); | |
| 100 | return began <= offset && offset <= ended; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Answers whether the caret's offset into the paragraph is before the given | |
| 105 | * offset. | |
| 106 | * | |
| 107 | * @param offset Compared against the caret's paragraph offset. | |
| 108 | * @return {@code true} the caret's offset is before the given offset. | |
| 109 | */ | |
| 110 | public boolean isBeforeColumn( final int offset ) { | |
| 111 | return getParaOffset() < offset; | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Answers whether the caret's offset into the text is before the given | |
| 116 | * text offset. | |
| 117 | * | |
| 118 | * @param offset Compared against the caret's text offset. | |
| 119 | * @return {@code true} the caret's offset is after the given offset. | |
| 120 | */ | |
| 121 | public boolean isAfterColumn( final int offset ) { | |
| 122 | return getParaOffset() > offset; | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Answers whether the caret's offset into the text exceeds the length of | |
| 127 | * the text. | |
| 128 | * | |
| 129 | * @return {@code true} when the caret is at the end of the text boundary. | |
| 130 | */ | |
| 131 | public boolean isAfterText() { | |
| 132 | return getTextOffset() >= getTextLength(); | |
| 133 | } | |
| 134 | ||
| 135 | public boolean isAfter( final int offset ) { | |
| 136 | return offset >= getTextOffset(); | |
| 137 | } | |
| 138 | ||
| 139 | private int getParagraph() { | |
| 140 | return mMutator.mParagraph.getValue(); | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Returns the number of lines in the text editor. | |
| 145 | * | |
| 146 | * @return The size of the text editor's paragraph list plus one. | |
| 147 | */ | |
| 148 | private int getParagraphCount() { | |
| 149 | return mMutator.mParagraphs.size() + 1; | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Returns the absolute position of the caret within the entire document. | |
| 154 | * | |
| 155 | * @return A zero-based index of the caret position. | |
| 156 | */ | |
| 157 | private int getTextOffset() { | |
| 158 | return mMutator.mTextOffset.getValue(); | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Returns the position of the caret within the current paragraph being | |
| 163 | * edited. | |
| 164 | * | |
| 165 | * @return A zero-based index of the caret position relative to the | |
| 166 | * current paragraph. | |
| 167 | */ | |
| 168 | private int getParaOffset() { | |
| 169 | return mMutator.mParaOffset.getValue(); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Returns the total number of characters in the document being edited. | |
| 174 | * | |
| 175 | * @return A zero-based count of the total characters in the document. | |
| 176 | */ | |
| 177 | private int getTextLength() { | |
| 178 | return mMutator.mTextLength.getValue(); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Returns a human-readable string that shows the current caret position | |
| 183 | * within the text. Typically this will include the current line number, | |
| 184 | * the number of lines, and the character offset into the text. | |
| 185 | * | |
| 186 | * @return A string to present to an end user. | |
| 187 | */ | |
| 188 | @Override | |
| 189 | public String toString() { | |
| 190 | return get( STATUS_BAR_LINE, | |
| 191 | getParagraph() + 1, | |
| 192 | getParagraphCount(), | |
| 193 | getTextOffset() + 1 ); | |
| 194 | } | |
| 195 | } | |
| 196 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown; | |
| 3 | ||
| 4 | import com.keenwrite.Constants; | |
| 5 | import com.vladsch.flexmark.html.AttributeProvider; | |
| 6 | import com.vladsch.flexmark.html.AttributeProviderFactory; | |
| 7 | import com.vladsch.flexmark.html.IndependentAttributeProviderFactory; | |
| 8 | import com.vladsch.flexmark.html.renderer.AttributablePart; | |
| 9 | import com.vladsch.flexmark.html.renderer.LinkResolverContext; | |
| 10 | import com.vladsch.flexmark.util.ast.Node; | |
| 11 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 12 | import com.vladsch.flexmark.util.html.AttributeImpl; | |
| 13 | import com.vladsch.flexmark.util.html.MutableAttributes; | |
| 14 | import org.jetbrains.annotations.NotNull; | |
| 15 | ||
| 16 | import static com.keenwrite.Constants.CARET_ID; | |
| 17 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 18 | import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension; | |
| 19 | ||
| 20 | /** | |
| 21 | * Responsible for giving most block-level elements a unique identifier | |
| 22 | * attribute. The identifier is used to coordinate scrolling. | |
| 23 | */ | |
| 24 | public class CaretExtension implements HtmlRendererExtension { | |
| 25 | ||
| 26 | private final Caret mCaret; | |
| 27 | ||
| 28 | private CaretExtension( final Caret caret ) { | |
| 29 | mCaret = caret; | |
| 30 | } | |
| 31 | ||
| 32 | public static CaretExtension create( final Caret caret ) { | |
| 33 | return new CaretExtension( caret ); | |
| 34 | } | |
| 35 | ||
| 36 | @Override | |
| 37 | public void extend( | |
| 38 | final Builder builder, @NotNull final String rendererType ) { | |
| 39 | builder.attributeProviderFactory( | |
| 40 | IdAttributeProvider.createFactory( mCaret ) ); | |
| 41 | } | |
| 42 | ||
| 43 | @Override | |
| 44 | public void rendererOptions( @NotNull final MutableDataHolder options ) { | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Responsible for creating the id attribute. This class is instantiated | |
| 49 | * once: for the HTML element containing the {@link Constants#CARET_ID}. | |
| 50 | */ | |
| 51 | public static class IdAttributeProvider implements AttributeProvider { | |
| 52 | private final Caret mCaret; | |
| 53 | ||
| 54 | public IdAttributeProvider( final Caret caret ) { | |
| 55 | mCaret = caret; | |
| 56 | } | |
| 57 | ||
| 58 | private static AttributeProviderFactory createFactory( | |
| 59 | final Caret caret ) { | |
| 60 | return new IndependentAttributeProviderFactory() { | |
| 61 | @Override | |
| 62 | public @NotNull AttributeProvider apply( | |
| 63 | @NotNull final LinkResolverContext context ) { | |
| 64 | return new IdAttributeProvider( caret ); | |
| 65 | } | |
| 66 | }; | |
| 67 | } | |
| 68 | ||
| 69 | @Override | |
| 70 | public void setAttributes( @NotNull Node curr, | |
| 71 | @NotNull AttributablePart part, | |
| 72 | @NotNull MutableAttributes attributes ) { | |
| 73 | final var outside = mCaret.isAfterText() ? 1 : 0; | |
| 74 | final var began = curr.getStartOffset(); | |
| 75 | final var ended = curr.getEndOffset() + outside; | |
| 76 | final var prev = curr.getPrevious(); | |
| 77 | ||
| 78 | // If the caret is within the bounds of the current node or the | |
| 79 | // caret is within the bounds of the end of the previous node and | |
| 80 | // the start of the current node, then mark the current node with | |
| 81 | // a caret indicator. | |
| 82 | if( mCaret.isBetweenText( began, ended ) || | |
| 83 | prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) { | |
| 84 | // This line empowers synchronizing the text editor with the preview. | |
| 85 | attributes.addValue( AttributeImpl.of( "id", CARET_ID ) ); | |
| 86 | } | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown; | |
| 3 | ||
| 4 | import com.keenwrite.processors.DefinitionProcessor; | |
| 5 | import com.keenwrite.processors.IdentityProcessor; | |
| 6 | import com.keenwrite.processors.ProcessorContext; | |
| 7 | import com.vladsch.flexmark.ast.FencedCodeBlock; | |
| 8 | import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory; | |
| 9 | import com.vladsch.flexmark.html.renderer.NodeRenderer; | |
| 10 | import com.vladsch.flexmark.html.renderer.NodeRenderingHandler; | |
| 11 | import com.vladsch.flexmark.util.data.DataHolder; | |
| 12 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 13 | import com.vladsch.flexmark.util.sequence.BasedSequence; | |
| 14 | import org.jetbrains.annotations.NotNull; | |
| 15 | ||
| 16 | import java.io.ByteArrayOutputStream; | |
| 17 | import java.util.HashSet; | |
| 18 | import java.util.Set; | |
| 19 | import java.util.zip.Deflater; | |
| 20 | ||
| 21 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 22 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 23 | import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension; | |
| 24 | import static com.vladsch.flexmark.html.renderer.LinkType.LINK; | |
| 25 | import static java.lang.String.format; | |
| 26 | import static java.util.Base64.getUrlEncoder; | |
| 27 | import static java.util.zip.Deflater.BEST_COMPRESSION; | |
| 28 | import static java.util.zip.Deflater.FULL_FLUSH; | |
| 29 | ||
| 30 | /** | |
| 31 | * Responsible for converting textual diagram descriptions into HTML image | |
| 32 | * elements. | |
| 33 | */ | |
| 34 | public class FencedBlockExtension implements HtmlRendererExtension { | |
| 35 | private final static String DIAGRAM_STYLE = "diagram-"; | |
| 36 | private final static int DIAGRAM_STYLE_LEN = DIAGRAM_STYLE.length(); | |
| 37 | ||
| 38 | private final DefinitionProcessor mProcessor; | |
| 39 | ||
| 40 | public FencedBlockExtension( final ProcessorContext context ) { | |
| 41 | assert context != null; | |
| 42 | mProcessor = new DefinitionProcessor( IdentityProcessor.INSTANCE, context ); | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Creates a new parser for fenced blocks. This calls out to a web service | |
| 47 | * to generate SVG files of text diagrams. | |
| 48 | * <p> | |
| 49 | * Internally, this creates a {@link DefinitionProcessor} to substitute | |
| 50 | * variable definitions. This is necessary because the order of processors | |
| 51 | * matters. If the {@link DefinitionProcessor} comes before an instance of | |
| 52 | * {@link MarkdownProcessor}, for example, then the caret position in the | |
| 53 | * preview pane will not align with the caret position in the editor | |
| 54 | * pane. The {@link MarkdownProcessor} must come before all else. However, | |
| 55 | * when parsing fenced blocks, the variables within the block must be | |
| 56 | * interpolated before being sent to the diagram web service. | |
| 57 | * </p> | |
| 58 | * | |
| 59 | * @param context Used to create a new {@link DefinitionProcessor}. | |
| 60 | * @return A new {@link FencedBlockExtension} capable of shunting ASCII | |
| 61 | * diagrams to a service for conversion to SVG. | |
| 62 | */ | |
| 63 | public static FencedBlockExtension create( final ProcessorContext context ) { | |
| 64 | return new FencedBlockExtension( context ); | |
| 65 | } | |
| 66 | ||
| 67 | @Override | |
| 68 | public void rendererOptions( @NotNull final MutableDataHolder options ) { | |
| 69 | } | |
| 70 | ||
| 71 | @Override | |
| 72 | public void extend( | |
| 73 | @NotNull final Builder builder, @NotNull final String rendererType ) { | |
| 74 | builder.nodeRendererFactory( new Factory() ); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Converts the given {@link BasedSequence} to a lowercase value. | |
| 79 | * | |
| 80 | * @param text The character string to convert to lowercase. | |
| 81 | * @return The lowercase text value, or the empty string for no text. | |
| 82 | */ | |
| 83 | private static String sanitize( final BasedSequence text ) { | |
| 84 | assert text != null; | |
| 85 | return text.toString().toLowerCase(); | |
| 86 | } | |
| 87 | ||
| 88 | private class CustomRenderer implements NodeRenderer { | |
| 89 | ||
| 90 | @Override | |
| 91 | public Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() { | |
| 92 | final var set = new HashSet<NodeRenderingHandler<?>>(); | |
| 93 | ||
| 94 | set.add( new NodeRenderingHandler<>( | |
| 95 | FencedCodeBlock.class, ( node, context, html ) -> { | |
| 96 | final var style = sanitize( node.getInfo() ); | |
| 97 | ||
| 98 | if( style.startsWith( DIAGRAM_STYLE ) ) { | |
| 99 | final var type = style.substring( DIAGRAM_STYLE_LEN ); | |
| 100 | final var content = node.getContentChars().normalizeEOL(); | |
| 101 | final var text = FencedBlockExtension.this.mProcessor.apply( content ); | |
| 102 | final var encoded = encode( text ); | |
| 103 | final var source = format( | |
| 104 | "https://kroki.io/%s/svg/%s", type, encoded ); | |
| 105 | ||
| 106 | final var link = context.resolveLink( LINK, source, false ); | |
| 107 | ||
| 108 | html.attr( "src", source ); | |
| 109 | html.withAttr( link ); | |
| 110 | html.tagVoid( "img" ); | |
| 111 | } | |
| 112 | else { | |
| 113 | context.delegateRender(); | |
| 114 | } | |
| 115 | } ) ); | |
| 116 | ||
| 117 | return set; | |
| 118 | } | |
| 119 | ||
| 120 | private byte[] compress( byte[] source ) { | |
| 121 | final var inLen = source.length; | |
| 122 | final var result = new byte[ inLen ]; | |
| 123 | final var deflater = new Deflater( BEST_COMPRESSION ); | |
| 124 | ||
| 125 | deflater.setInput( source, 0, inLen ); | |
| 126 | deflater.finish(); | |
| 127 | final var outLen = deflater.deflate( result, 0, inLen, FULL_FLUSH ); | |
| 128 | deflater.end(); | |
| 129 | ||
| 130 | try( final var out = new ByteArrayOutputStream() ) { | |
| 131 | out.write( result, 0, outLen ); | |
| 132 | return out.toByteArray(); | |
| 133 | } catch( final Exception ex ) { | |
| 134 | clue( ex ); | |
| 135 | throw new RuntimeException( ex ); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | private String encode( final String decoded ) { | |
| 140 | return getUrlEncoder().encodeToString( compress( decoded.getBytes() ) ); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | private class Factory implements DelegatingNodeRendererFactory { | |
| 145 | public Factory() {} | |
| 146 | ||
| 147 | @NotNull | |
| 148 | @Override | |
| 149 | public NodeRenderer apply( @NotNull final DataHolder options ) { | |
| 150 | return new CustomRenderer(); | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * Return {@code null} to indicate this may delegate to the core renderer. | |
| 155 | */ | |
| 156 | @Override | |
| 157 | public Set<Class<?>> getDelegates() { | |
| 158 | return null; | |
| 159 | } | |
| 160 | } | |
| 161 | } | |
| 162 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown; | |
| 3 | ||
| 4 | import com.keenwrite.exceptions.MissingFileException; | |
| 5 | import com.keenwrite.preferences.Workspace; | |
| 6 | import com.vladsch.flexmark.ast.Image; | |
| 7 | import com.vladsch.flexmark.html.IndependentLinkResolverFactory; | |
| 8 | import com.vladsch.flexmark.html.LinkResolver; | |
| 9 | import com.vladsch.flexmark.html.renderer.LinkResolverBasicContext; | |
| 10 | import com.vladsch.flexmark.html.renderer.ResolvedLink; | |
| 11 | import com.vladsch.flexmark.util.ast.Node; | |
| 12 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 13 | import org.jetbrains.annotations.NotNull; | |
| 14 | import org.renjin.repackaged.guava.base.Splitter; | |
| 15 | ||
| 16 | import java.io.File; | |
| 17 | import java.nio.file.Path; | |
| 18 | import java.nio.file.Paths; | |
| 19 | ||
| 20 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 21 | import static com.keenwrite.preferences.Workspace.KEY_IMAGES_DIR; | |
| 22 | import static com.keenwrite.preferences.Workspace.KEY_IMAGES_ORDER; | |
| 23 | import static com.keenwrite.util.ProtocolScheme.getProtocol; | |
| 24 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 25 | import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension; | |
| 26 | import static com.vladsch.flexmark.html.renderer.LinkStatus.VALID; | |
| 27 | import static java.lang.String.format; | |
| 28 | ||
| 29 | /** | |
| 30 | * Responsible for ensuring that images can be rendered relative to a path. | |
| 31 | * This allows images to be located virtually anywhere. | |
| 32 | */ | |
| 33 | public class ImageLinkExtension implements HtmlRendererExtension { | |
| 34 | ||
| 35 | /** | |
| 36 | * Creates an extension capable of using a relative path to embed images. | |
| 37 | * | |
| 38 | * @param basePath The directory to search for images, either directly or | |
| 39 | * through the images directory setting, not {@code null}. | |
| 40 | * @param workspace Contains user preferences for image directory and image | |
| 41 | * file name extension lookup order. | |
| 42 | * @return The new {@link ImageLinkExtension}, not {@code null}. | |
| 43 | */ | |
| 44 | public static ImageLinkExtension create( | |
| 45 | @NotNull final Path basePath, | |
| 46 | @NotNull final Workspace workspace ) { | |
| 47 | return new ImageLinkExtension( basePath, workspace ); | |
| 48 | } | |
| 49 | ||
| 50 | private final Path mBasePath; | |
| 51 | private final Workspace mWorkspace; | |
| 52 | ||
| 53 | private ImageLinkExtension( | |
| 54 | @NotNull final Path basePath, @NotNull final Workspace workspace ) { | |
| 55 | mBasePath = basePath; | |
| 56 | mWorkspace = workspace; | |
| 57 | } | |
| 58 | ||
| 59 | @Override | |
| 60 | public void rendererOptions( @NotNull final MutableDataHolder options ) { | |
| 61 | } | |
| 62 | ||
| 63 | @Override | |
| 64 | public void extend( | |
| 65 | @NotNull final Builder builder, @NotNull final String rendererType ) { | |
| 66 | builder.linkResolverFactory( new Factory() ); | |
| 67 | } | |
| 68 | ||
| 69 | private class Factory extends IndependentLinkResolverFactory { | |
| 70 | @Override | |
| 71 | public @NotNull LinkResolver apply( | |
| 72 | @NotNull final LinkResolverBasicContext context ) { | |
| 73 | return new ImageLinkResolver(); | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | private class ImageLinkResolver implements LinkResolver { | |
| 78 | public ImageLinkResolver() { | |
| 79 | } | |
| 80 | ||
| 81 | @NotNull | |
| 82 | @Override | |
| 83 | public ResolvedLink resolveLink( | |
| 84 | @NotNull final Node node, | |
| 85 | @NotNull final LinkResolverBasicContext context, | |
| 86 | @NotNull final ResolvedLink link ) { | |
| 87 | return node instanceof Image ? resolve( link ) : link; | |
| 88 | } | |
| 89 | ||
| 90 | private ResolvedLink resolve( final ResolvedLink link ) { | |
| 91 | var uri = link.getUrl(); | |
| 92 | final var protocol = getProtocol( uri ); | |
| 93 | ||
| 94 | if( protocol.isHttp() ) { | |
| 95 | return valid( link, uri ); | |
| 96 | } | |
| 97 | ||
| 98 | // Determine the fully-qualified file name (fqfn). | |
| 99 | final var fqfn = Paths.get( getBasePath().toString(), uri ).toFile(); | |
| 100 | ||
| 101 | if( fqfn.isFile() ) { | |
| 102 | return valid( link, uri ); | |
| 103 | } | |
| 104 | ||
| 105 | // At this point either the image directory is qualified or needs to be | |
| 106 | // qualified using the image prefix, as set in the user preferences. | |
| 107 | try { | |
| 108 | final var imagePrefix = getImagePrefix(); | |
| 109 | final var basePath = getBasePath().resolve( imagePrefix ); | |
| 110 | ||
| 111 | final var imagePathPrefix = Path.of( basePath.toString(), uri ); | |
| 112 | final var suffixes = getImageExtensions(); | |
| 113 | boolean missing = true; | |
| 114 | ||
| 115 | // Iterate over the user's preferred image file type extensions. | |
| 116 | for( final var ext : Splitter.on( ' ' ).split( suffixes ) ) { | |
| 117 | final var imagePath = format( "%s.%s", imagePathPrefix, ext ); | |
| 118 | final var file = new File( imagePath ); | |
| 119 | ||
| 120 | if( file.exists() ) { | |
| 121 | uri += '.' + ext; | |
| 122 | final var path = Path.of( imagePrefix.toString(), uri ); | |
| 123 | uri = path.normalize().toString(); | |
| 124 | missing = false; | |
| 125 | break; | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | if( missing ) { | |
| 130 | throw new MissingFileException( imagePathPrefix + ".*" ); | |
| 131 | } | |
| 132 | ||
| 133 | return valid( link, uri ); | |
| 134 | } catch( final Exception ex ) { | |
| 135 | clue( ex ); | |
| 136 | } | |
| 137 | ||
| 138 | return link; | |
| 139 | } | |
| 140 | ||
| 141 | private ResolvedLink valid( final ResolvedLink link, final String url ) { | |
| 142 | return link.withStatus( VALID ).withUrl( url ); | |
| 143 | } | |
| 144 | ||
| 145 | private Path getImagePrefix() { | |
| 146 | return mWorkspace.toFile( KEY_IMAGES_DIR ).toPath(); | |
| 147 | } | |
| 148 | ||
| 149 | private String getImageExtensions() { | |
| 150 | return mWorkspace.toString( KEY_IMAGES_ORDER ); | |
| 151 | } | |
| 152 | ||
| 153 | private Path getBasePath() { | |
| 154 | return mBasePath; | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors.markdown; |
| 3 | 3 | |
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | 4 | import com.keenwrite.io.MediaType; |
| 6 | import com.keenwrite.preferences.Workspace; | |
| 7 | import com.keenwrite.processors.*; | |
| 8 | import com.keenwrite.processors.markdown.r.RExtension; | |
| 5 | import com.keenwrite.processors.ExecutorProcessor; | |
| 6 | import com.keenwrite.processors.Processor; | |
| 7 | import com.keenwrite.processors.ProcessorContext; | |
| 8 | import com.keenwrite.processors.markdown.extensions.FencedBlockExtension; | |
| 9 | import com.keenwrite.processors.markdown.extensions.ImageLinkExtension; | |
| 10 | import com.keenwrite.processors.markdown.extensions.caret.CaretExtension; | |
| 11 | import com.keenwrite.processors.markdown.extensions.r.RExtension; | |
| 12 | import com.keenwrite.processors.markdown.extensions.tex.TeXExtension; | |
| 9 | 13 | import com.vladsch.flexmark.ext.definition.DefinitionExtension; |
| 10 | 14 | import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension; |
| ... | ||
| 19 | 23 | import com.vladsch.flexmark.util.misc.Extension; |
| 20 | 24 | |
| 21 | import java.nio.file.Path; | |
| 22 | import java.util.Collection; | |
| 23 | import java.util.HashSet; | |
| 25 | import java.util.ArrayList; | |
| 26 | import java.util.List; | |
| 24 | 27 | |
| 25 | import static com.keenwrite.Constants.DEFAULT_DIRECTORY; | |
| 26 | import static com.keenwrite.ExportFormat.NONE; | |
| 27 | 28 | import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; |
| 28 | 29 | import static com.keenwrite.io.MediaType.TEXT_R_XML; |
| 30 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 29 | 31 | |
| 30 | 32 | /** |
| 31 | 33 | * Responsible for parsing a Markdown document and rendering it as HTML. |
| 32 | 34 | */ |
| 33 | 35 | public class MarkdownProcessor extends ExecutorProcessor<String> { |
| 36 | ||
| 37 | private static final List<Extension> DEFAULT_EXTENSIONS = | |
| 38 | createDefaultExtensions(); | |
| 34 | 39 | |
| 35 | 40 | private final IParse mParser; |
| 36 | 41 | private final IRender mRenderer; |
| 37 | 42 | |
| 38 | 43 | private MarkdownProcessor( |
| 39 | 44 | final Processor<String> successor, |
| 40 | final Collection<Extension> extensions ) { | |
| 45 | final List<Extension> extensions ) { | |
| 41 | 46 | super( successor ); |
| 42 | 47 | |
| 43 | 48 | mParser = Parser.builder().extensions( extensions ).build(); |
| 44 | 49 | mRenderer = HtmlRenderer.builder().extensions( extensions ).build(); |
| 45 | } | |
| 46 | ||
| 47 | public static MarkdownProcessor create( final Workspace workspace ) { | |
| 48 | return create( IdentityProcessor.INSTANCE, workspace, DEFAULT_DIRECTORY ); | |
| 49 | 50 | } |
| 50 | 51 | |
| 51 | 52 | public static MarkdownProcessor create( final ProcessorContext context ) { |
| 52 | return create( IdentityProcessor.INSTANCE, context ); | |
| 53 | } | |
| 54 | ||
| 55 | public static MarkdownProcessor create( | |
| 56 | final Processor<String> successor, | |
| 57 | final Workspace workspace, | |
| 58 | final Path dir ) { | |
| 59 | final var extensions = createExtensions( NONE, workspace, dir ); | |
| 60 | return new MarkdownProcessor( successor, extensions ); | |
| 53 | return create( IDENTITY, context ); | |
| 61 | 54 | } |
| 62 | 55 | |
| 63 | 56 | public static MarkdownProcessor create( |
| 64 | 57 | final Processor<String> successor, final ProcessorContext context ) { |
| 65 | 58 | final var extensions = createExtensions( context ); |
| 66 | 59 | return new MarkdownProcessor( successor, extensions ); |
| 60 | } | |
| 61 | ||
| 62 | private static List<Extension> createEmptyExtensions() { | |
| 63 | return new ArrayList<>(); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Instantiates a number of extensions to be applied when parsing. These | |
| 68 | * are typically typographic extensions that convert characters into | |
| 69 | * HTML entities. | |
| 70 | * | |
| 71 | * @return A {@link List} of {@link Extension} instances that | |
| 72 | * change the {@link Parser}'s behaviour. | |
| 73 | */ | |
| 74 | private static List<Extension> createDefaultExtensions() { | |
| 75 | final List<Extension> extensions = new ArrayList<>(); | |
| 76 | extensions.add( DefinitionExtension.create() ); | |
| 77 | extensions.add( StrikethroughSubscriptExtension.create() ); | |
| 78 | extensions.add( SuperscriptExtension.create() ); | |
| 79 | extensions.add( TablesExtension.create() ); | |
| 80 | extensions.add( TypographicExtension.create() ); | |
| 81 | return extensions; | |
| 67 | 82 | } |
| 68 | 83 | |
| ... | ||
| 78 | 93 | * @param context Contains necessary information needed to create extensions |
| 79 | 94 | * used by the Markdown parser. |
| 80 | * @return {@link Collection} of extensions invoked when parsing Markdown. | |
| 95 | * @return {@link List} of extensions invoked when parsing Markdown. | |
| 81 | 96 | */ |
| 82 | private static Collection<Extension> createExtensions( | |
| 97 | private static List<Extension> createExtensions( | |
| 83 | 98 | final ProcessorContext context ) { |
| 84 | final var path = context.getPath(); | |
| 85 | final var dir = context.getBasePath(); | |
| 86 | final var format = context.getExportFormat(); | |
| 87 | final var workspace = context.getWorkspace(); | |
| 88 | final var extensions = createExtensions( format, workspace, dir ); | |
| 99 | final var extensions = createEmptyExtensions(); | |
| 100 | final var editorFile = context.getPath(); | |
| 89 | 101 | |
| 90 | final var mediaType = MediaType.valueFrom( path ); | |
| 102 | final var mediaType = MediaType.valueFrom( editorFile ); | |
| 91 | 103 | if( mediaType == TEXT_R_MARKDOWN || mediaType == TEXT_R_XML ) { |
| 92 | extensions.add( RExtension.create() ); | |
| 104 | extensions.add( RExtension.create( context ) ); | |
| 93 | 105 | } |
| 94 | 106 | |
| 107 | extensions.addAll( DEFAULT_EXTENSIONS ); | |
| 108 | extensions.add( ImageLinkExtension.create( context ) ); | |
| 109 | extensions.add( TeXExtension.create( context ) ); | |
| 95 | 110 | extensions.add( FencedBlockExtension.create( context ) ); |
| 96 | extensions.add( CaretExtension.create( context.getCaret() ) ); | |
| 97 | ||
| 98 | return extensions; | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Creates parser extensions that tweak the parsing engine based on various | |
| 103 | * conditions. For example, this will add a new {@link TeXExtension} that | |
| 104 | * can export TeX as either SVG or TeX macros. The tweak also includes the | |
| 105 | * ability to keep inline R statements, rather than convert them to inline | |
| 106 | * code elements, so that the {@link InlineRProcessor} can interpret the | |
| 107 | * R statements. | |
| 108 | * | |
| 109 | * @param dir Directory for referencing image files via relative paths | |
| 110 | * and dynamic file types. | |
| 111 | * @param format TeX export format to use when generating HTMl documents. | |
| 112 | * @return {@link Collection} of extensions invoked when parsing Markdown. | |
| 113 | */ | |
| 114 | private static Collection<Extension> createExtensions( | |
| 115 | final ExportFormat format, final Workspace workspace, final Path dir ) { | |
| 116 | final var extensions = createDefaultExtensions(); | |
| 117 | ||
| 118 | extensions.add( ImageLinkExtension.create( dir, workspace ) ); | |
| 119 | extensions.add( TeXExtension.create( format ) ); | |
| 120 | ||
| 121 | return extensions; | |
| 122 | } | |
| 111 | extensions.add( CaretExtension.create( context ) ); | |
| 123 | 112 | |
| 124 | /** | |
| 125 | * Instantiates a number of extensions to be applied when parsing. These | |
| 126 | * are typically typographic extensions that convert characters into | |
| 127 | * HTML entities. | |
| 128 | * | |
| 129 | * @return A {@link Collection} of {@link Extension} instances that | |
| 130 | * change the {@link Parser}'s behaviour. | |
| 131 | */ | |
| 132 | private static Collection<Extension> createDefaultExtensions() { | |
| 133 | final var extensions = new HashSet<Extension>(); | |
| 134 | extensions.add( DefinitionExtension.create() ); | |
| 135 | extensions.add( StrikethroughSubscriptExtension.create() ); | |
| 136 | extensions.add( SuperscriptExtension.create() ); | |
| 137 | extensions.add( TablesExtension.create() ); | |
| 138 | extensions.add( TypographicExtension.create() ); | |
| 139 | 113 | return extensions; |
| 140 | 114 | } |
| ... | ||
| 149 | 123 | @Override |
| 150 | 124 | public String apply( final String markdown ) { |
| 151 | return toHtml( markdown ); | |
| 125 | return toHtml( parse( markdown ) ); | |
| 152 | 126 | } |
| 153 | 127 | |
| 154 | 128 | /** |
| 155 | * Returns the AST in the form of a node for the given markdown document. This | |
| 129 | * Returns the AST in the form of a node for the given Markdown document. This | |
| 156 | 130 | * can be used, for example, to determine if a hyperlink exists inside of a |
| 157 | 131 | * paragraph. |
| 158 | 132 | * |
| 159 | * @param markdown The markdown to convert into an AST. | |
| 160 | * @return The markdown AST for the given text (usually a paragraph). | |
| 133 | * @param markdown The Markdown to convert into an AST. | |
| 134 | * @return The Markdown AST for the given text (usually a paragraph). | |
| 161 | 135 | */ |
| 162 | 136 | public Node toNode( final String markdown ) { |
| ... | ||
| 175 | 149 | |
| 176 | 150 | /** |
| 177 | * Helper method to create an AST given some markdown. | |
| 151 | * Helper method to create an AST given some Markdown. | |
| 178 | 152 | * |
| 179 | * @param markdown The markdown to parse. | |
| 180 | * @return The root node of the markdown tree. | |
| 153 | * @param markdown The Markdown to parse. | |
| 154 | * @return The root node of the Markdown tree. | |
| 181 | 155 | */ |
| 182 | 156 | private Node parse( final String markdown ) { |
| 183 | 157 | return getParser().parse( markdown ); |
| 184 | } | |
| 185 | ||
| 186 | /** | |
| 187 | * Converts a string of markdown into HTML. | |
| 188 | * | |
| 189 | * @param markdown The markdown text to convert to HTML, must not be null. | |
| 190 | * @return The markdown rendered as an HTML document. | |
| 191 | */ | |
| 192 | private String toHtml( final String markdown ) { | |
| 193 | return toHtml( parse( markdown ) ); | |
| 194 | 158 | } |
| 195 | 159 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.processors.markdown.tex.TeXInlineDelimiterProcessor; | |
| 6 | import com.keenwrite.processors.markdown.tex.TexNodeRenderer.Factory; | |
| 7 | import com.vladsch.flexmark.html.HtmlRenderer; | |
| 8 | import com.vladsch.flexmark.parser.Parser; | |
| 9 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 10 | import com.vladsch.flexmark.util.misc.Extension; | |
| 11 | import org.jetbrains.annotations.NotNull; | |
| 12 | ||
| 13 | import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension; | |
| 14 | import static com.vladsch.flexmark.parser.Parser.ParserExtension; | |
| 15 | ||
| 16 | /** | |
| 17 | * Responsible for wrapping delimited TeX code in Markdown into an XML element | |
| 18 | * that the HTML renderer can handle. For example, {@code $E=mc^2$} becomes | |
| 19 | * {@code <tex>E=mc^2</tex>} when passed to HTML renderer. The HTML renderer | |
| 20 | * is responsible for converting the TeX code for display. This avoids inserting | |
| 21 | * SVG code into the Markdown document, which the parser would then have to | |
| 22 | * iterate---a <em>very</em> wasteful operation that impacts front-end | |
| 23 | * performance. | |
| 24 | */ | |
| 25 | public class TeXExtension implements ParserExtension, HtmlRendererExtension { | |
| 26 | /** | |
| 27 | * Controls how the node renderer produces TeX code within HTML output. | |
| 28 | */ | |
| 29 | private final ExportFormat mExportFormat; | |
| 30 | ||
| 31 | /** | |
| 32 | * Creates an extension capable of handling delimited TeX code in Markdown. | |
| 33 | * | |
| 34 | * @return The new {@link TeXExtension}, never {@code null}. | |
| 35 | */ | |
| 36 | public static TeXExtension create( final ExportFormat format ) { | |
| 37 | return new TeXExtension( format ); | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Force using the {@link #create(ExportFormat)} method for consistency with | |
| 42 | * the other {@link Extension} creation invocations. | |
| 43 | */ | |
| 44 | private TeXExtension( final ExportFormat exportFormat ) { | |
| 45 | mExportFormat = exportFormat; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Adds the TeX extension for HTML document export types. | |
| 50 | * | |
| 51 | * @param builder The document builder. | |
| 52 | * @param rendererType Indicates the document type to be built. | |
| 53 | */ | |
| 54 | @Override | |
| 55 | public void extend( @NotNull final HtmlRenderer.Builder builder, | |
| 56 | @NotNull final String rendererType ) { | |
| 57 | if( "HTML".equalsIgnoreCase( rendererType ) ) { | |
| 58 | builder.nodeRendererFactory( new Factory( mExportFormat ) ); | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | @Override | |
| 63 | public void extend( final Parser.Builder builder ) { | |
| 64 | builder.customDelimiterProcessor( new TeXInlineDelimiterProcessor() ); | |
| 65 | } | |
| 66 | ||
| 67 | @Override | |
| 68 | public void rendererOptions( @NotNull final MutableDataHolder options ) { | |
| 69 | } | |
| 70 | ||
| 71 | @Override | |
| 72 | public void parserOptions( final MutableDataHolder options ) { | |
| 73 | } | |
| 74 | } | |
| 75 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions; | |
| 3 | ||
| 4 | import com.keenwrite.processors.DefinitionProcessor; | |
| 5 | import com.keenwrite.processors.ProcessorContext; | |
| 6 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 7 | import com.vladsch.flexmark.ast.FencedCodeBlock; | |
| 8 | import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory; | |
| 9 | import com.vladsch.flexmark.html.renderer.NodeRenderer; | |
| 10 | import com.vladsch.flexmark.html.renderer.NodeRenderingHandler; | |
| 11 | import com.vladsch.flexmark.util.data.DataHolder; | |
| 12 | import com.vladsch.flexmark.util.sequence.BasedSequence; | |
| 13 | import org.jetbrains.annotations.NotNull; | |
| 14 | ||
| 15 | import java.io.ByteArrayOutputStream; | |
| 16 | import java.util.HashSet; | |
| 17 | import java.util.Set; | |
| 18 | import java.util.zip.Deflater; | |
| 19 | ||
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 21 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 22 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 23 | import static com.vladsch.flexmark.html.renderer.LinkType.LINK; | |
| 24 | import static java.lang.String.format; | |
| 25 | import static java.util.Base64.getUrlEncoder; | |
| 26 | import static java.util.zip.Deflater.BEST_COMPRESSION; | |
| 27 | import static java.util.zip.Deflater.FULL_FLUSH; | |
| 28 | ||
| 29 | /** | |
| 30 | * Responsible for converting textual diagram descriptions into HTML image | |
| 31 | * elements. | |
| 32 | */ | |
| 33 | public class FencedBlockExtension extends HtmlRendererAdapter { | |
| 34 | private final static String DIAGRAM_STYLE = "diagram-"; | |
| 35 | private final static int DIAGRAM_STYLE_LEN = DIAGRAM_STYLE.length(); | |
| 36 | ||
| 37 | private final DefinitionProcessor mProcessor; | |
| 38 | ||
| 39 | public FencedBlockExtension( final ProcessorContext context ) { | |
| 40 | assert context != null; | |
| 41 | mProcessor = new DefinitionProcessor( IDENTITY, context ); | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Creates a new parser for fenced blocks. This calls out to a web service | |
| 46 | * to generate SVG files of text diagrams. | |
| 47 | * <p> | |
| 48 | * Internally, this creates a {@link DefinitionProcessor} to substitute | |
| 49 | * variable definitions. This is necessary because the order of processors | |
| 50 | * matters. If the {@link DefinitionProcessor} comes before an instance of | |
| 51 | * {@link MarkdownProcessor}, for example, then the caret position in the | |
| 52 | * preview pane will not align with the caret position in the editor | |
| 53 | * pane. The {@link MarkdownProcessor} must come before all else. However, | |
| 54 | * when parsing fenced blocks, the variables within the block must be | |
| 55 | * interpolated before being sent to the diagram web service. | |
| 56 | * </p> | |
| 57 | * | |
| 58 | * @param context Used to create a new {@link DefinitionProcessor}. | |
| 59 | * @return A new {@link FencedBlockExtension} capable of shunting ASCII | |
| 60 | * diagrams to a service for conversion to SVG. | |
| 61 | */ | |
| 62 | public static FencedBlockExtension create( final ProcessorContext context ) { | |
| 63 | return new FencedBlockExtension( context ); | |
| 64 | } | |
| 65 | ||
| 66 | @Override | |
| 67 | public void extend( | |
| 68 | @NotNull final Builder builder, @NotNull final String rendererType ) { | |
| 69 | builder.nodeRendererFactory( new Factory() ); | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Converts the given {@link BasedSequence} to a lowercase value. | |
| 74 | * | |
| 75 | * @param text The character string to convert to lowercase. | |
| 76 | * @return The lowercase text value, or the empty string for no text. | |
| 77 | */ | |
| 78 | private static String sanitize( final BasedSequence text ) { | |
| 79 | assert text != null; | |
| 80 | return text.toString().toLowerCase(); | |
| 81 | } | |
| 82 | ||
| 83 | private class CustomRenderer implements NodeRenderer { | |
| 84 | ||
| 85 | @Override | |
| 86 | public Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() { | |
| 87 | final var set = new HashSet<NodeRenderingHandler<?>>(); | |
| 88 | ||
| 89 | set.add( new NodeRenderingHandler<>( | |
| 90 | FencedCodeBlock.class, ( node, context, html ) -> { | |
| 91 | final var style = sanitize( node.getInfo() ); | |
| 92 | ||
| 93 | if( style.startsWith( DIAGRAM_STYLE ) ) { | |
| 94 | final var type = style.substring( DIAGRAM_STYLE_LEN ); | |
| 95 | final var content = node.getContentChars().normalizeEOL(); | |
| 96 | final var text = FencedBlockExtension.this.mProcessor.apply( content ); | |
| 97 | final var encoded = encode( text ); | |
| 98 | final var source = format( | |
| 99 | "https://kroki.io/%s/svg/%s", type, encoded ); | |
| 100 | ||
| 101 | final var link = context.resolveLink( LINK, source, false ); | |
| 102 | ||
| 103 | html.attr( "src", source ); | |
| 104 | html.withAttr( link ); | |
| 105 | html.tagVoid( "img" ); | |
| 106 | } | |
| 107 | else { | |
| 108 | context.delegateRender(); | |
| 109 | } | |
| 110 | } ) ); | |
| 111 | ||
| 112 | return set; | |
| 113 | } | |
| 114 | ||
| 115 | private byte[] compress( byte[] source ) { | |
| 116 | final var inLen = source.length; | |
| 117 | final var result = new byte[ inLen ]; | |
| 118 | final var compressor = new Deflater( BEST_COMPRESSION ); | |
| 119 | ||
| 120 | compressor.setInput( source, 0, inLen ); | |
| 121 | compressor.finish(); | |
| 122 | final var outLen = compressor.deflate( result, 0, inLen, FULL_FLUSH ); | |
| 123 | compressor.end(); | |
| 124 | ||
| 125 | try( final var out = new ByteArrayOutputStream() ) { | |
| 126 | out.write( result, 0, outLen ); | |
| 127 | return out.toByteArray(); | |
| 128 | } catch( final Exception ex ) { | |
| 129 | clue( ex ); | |
| 130 | throw new RuntimeException( ex ); | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | private String encode( final String decoded ) { | |
| 135 | return getUrlEncoder().encodeToString( compress( decoded.getBytes() ) ); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | private class Factory implements DelegatingNodeRendererFactory { | |
| 140 | public Factory() {} | |
| 141 | ||
| 142 | @NotNull | |
| 143 | @Override | |
| 144 | public NodeRenderer apply( @NotNull final DataHolder options ) { | |
| 145 | return new CustomRenderer(); | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * Return {@code null} to indicate this may delegate to the core renderer. | |
| 150 | */ | |
| 151 | @Override | |
| 152 | public Set<Class<?>> getDelegates() { | |
| 153 | return null; | |
| 154 | } | |
| 155 | } | |
| 156 | } | |
| 1 | 157 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions; | |
| 3 | ||
| 4 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 5 | import org.jetbrains.annotations.NotNull; | |
| 6 | ||
| 7 | import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension; | |
| 8 | ||
| 9 | /** | |
| 10 | * Hides the {@link #rendererOptions(MutableDataHolder)} from subclasses | |
| 11 | * that would otherwise implement the {@link HtmlRendererExtension} interface. | |
| 12 | */ | |
| 13 | public abstract class HtmlRendererAdapter implements HtmlRendererExtension { | |
| 14 | /** | |
| 15 | * Empty, unused. | |
| 16 | * | |
| 17 | * @param options Ignored. | |
| 18 | */ | |
| 19 | @Override | |
| 20 | public void rendererOptions( @NotNull final MutableDataHolder options ) { | |
| 21 | } | |
| 22 | } | |
| 1 | 23 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions; | |
| 3 | ||
| 4 | import com.keenwrite.exceptions.MissingFileException; | |
| 5 | import com.keenwrite.preferences.Workspace; | |
| 6 | import com.keenwrite.processors.ProcessorContext; | |
| 7 | import com.vladsch.flexmark.ast.Image; | |
| 8 | import com.vladsch.flexmark.html.IndependentLinkResolverFactory; | |
| 9 | import com.vladsch.flexmark.html.LinkResolver; | |
| 10 | import com.vladsch.flexmark.html.renderer.LinkResolverBasicContext; | |
| 11 | import com.vladsch.flexmark.html.renderer.ResolvedLink; | |
| 12 | import com.vladsch.flexmark.util.ast.Node; | |
| 13 | import org.jetbrains.annotations.NotNull; | |
| 14 | import org.renjin.repackaged.guava.base.Splitter; | |
| 15 | ||
| 16 | import java.io.File; | |
| 17 | import java.nio.file.Path; | |
| 18 | import java.nio.file.Paths; | |
| 19 | ||
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 21 | import static com.keenwrite.preferences.Workspace.KEY_IMAGES_DIR; | |
| 22 | import static com.keenwrite.preferences.Workspace.KEY_IMAGES_ORDER; | |
| 23 | import static com.keenwrite.util.ProtocolScheme.getProtocol; | |
| 24 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 25 | import static com.vladsch.flexmark.html.renderer.LinkStatus.VALID; | |
| 26 | import static java.lang.String.format; | |
| 27 | ||
| 28 | /** | |
| 29 | * Responsible for ensuring that images can be rendered relative to a path. | |
| 30 | * This allows images to be located virtually anywhere. | |
| 31 | */ | |
| 32 | public class ImageLinkExtension extends HtmlRendererAdapter { | |
| 33 | ||
| 34 | private final Path mBaseDir; | |
| 35 | private final Workspace mWorkspace; | |
| 36 | ||
| 37 | private ImageLinkExtension( @NotNull final ProcessorContext context ) { | |
| 38 | mBaseDir = context.getBaseDir(); | |
| 39 | mWorkspace = context.getWorkspace(); | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Creates an extension capable of using a relative path to embed images. | |
| 44 | * | |
| 45 | * @param context Contains the base directory to search in for images. | |
| 46 | * @return The new {@link ImageLinkExtension}, not {@code null}. | |
| 47 | */ | |
| 48 | public static ImageLinkExtension create( | |
| 49 | @NotNull final ProcessorContext context ) { | |
| 50 | return new ImageLinkExtension( context ); | |
| 51 | } | |
| 52 | ||
| 53 | @Override | |
| 54 | public void extend( | |
| 55 | @NotNull final Builder builder, @NotNull final String rendererType ) { | |
| 56 | builder.linkResolverFactory( new Factory() ); | |
| 57 | } | |
| 58 | ||
| 59 | private class Factory extends IndependentLinkResolverFactory { | |
| 60 | @Override | |
| 61 | public @NotNull LinkResolver apply( | |
| 62 | @NotNull final LinkResolverBasicContext context ) { | |
| 63 | return new ImageLinkResolver(); | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | private class ImageLinkResolver implements LinkResolver { | |
| 68 | public ImageLinkResolver() { | |
| 69 | } | |
| 70 | ||
| 71 | @NotNull | |
| 72 | @Override | |
| 73 | public ResolvedLink resolveLink( | |
| 74 | @NotNull final Node node, | |
| 75 | @NotNull final LinkResolverBasicContext context, | |
| 76 | @NotNull final ResolvedLink link ) { | |
| 77 | return node instanceof Image ? resolve( link ) : link; | |
| 78 | } | |
| 79 | ||
| 80 | private ResolvedLink resolve( final ResolvedLink link ) { | |
| 81 | var uri = link.getUrl(); | |
| 82 | final var protocol = getProtocol( uri ); | |
| 83 | ||
| 84 | if( protocol.isHttp() ) { | |
| 85 | return valid( link, uri ); | |
| 86 | } | |
| 87 | ||
| 88 | // Determine the fully-qualified file name (fqfn). | |
| 89 | final var fqfn = Paths.get( getBaseDir().toString(), uri ).toFile(); | |
| 90 | ||
| 91 | if( fqfn.isFile() ) { | |
| 92 | return valid( link, uri ); | |
| 93 | } | |
| 94 | ||
| 95 | // At this point either the image directory is qualified or needs to be | |
| 96 | // qualified using the image prefix, as set in the user preferences. | |
| 97 | try { | |
| 98 | final var imagePrefix = getImagePrefix(); | |
| 99 | final var baseDir = getBaseDir().resolve( imagePrefix ); | |
| 100 | ||
| 101 | final var imagePrefixDir = Path.of( baseDir.toString(), uri ); | |
| 102 | final var suffixes = getImageExtensions(); | |
| 103 | boolean missing = true; | |
| 104 | ||
| 105 | // Iterate over the user's preferred image file type extensions. | |
| 106 | for( final var ext : Splitter.on( ' ' ).split( suffixes ) ) { | |
| 107 | final var imagePath = format( "%s.%s", imagePrefixDir, ext ); | |
| 108 | final var file = new File( imagePath ); | |
| 109 | ||
| 110 | if( file.exists() ) { | |
| 111 | uri += '.' + ext; | |
| 112 | final var path = Path.of( imagePrefix.toString(), uri ); | |
| 113 | uri = path.normalize().toString(); | |
| 114 | missing = false; | |
| 115 | break; | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | if( missing ) { | |
| 120 | throw new MissingFileException( imagePrefixDir + ".*" ); | |
| 121 | } | |
| 122 | ||
| 123 | return valid( link, uri ); | |
| 124 | } catch( final Exception ex ) { | |
| 125 | clue( ex ); | |
| 126 | } | |
| 127 | ||
| 128 | return link; | |
| 129 | } | |
| 130 | ||
| 131 | private ResolvedLink valid( final ResolvedLink link, final String url ) { | |
| 132 | return link.withStatus( VALID ).withUrl( url ); | |
| 133 | } | |
| 134 | ||
| 135 | private Path getImagePrefix() { | |
| 136 | return mWorkspace.toFile( KEY_IMAGES_DIR ).toPath(); | |
| 137 | } | |
| 138 | ||
| 139 | private String getImageExtensions() { | |
| 140 | return mWorkspace.toString( KEY_IMAGES_ORDER ); | |
| 141 | } | |
| 142 | ||
| 143 | private Path getBaseDir() { | |
| 144 | return mBaseDir; | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 1 | 148 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.caret; | |
| 3 | ||
| 4 | import com.keenwrite.Caret; | |
| 5 | import com.keenwrite.Constants; | |
| 6 | import com.keenwrite.processors.ProcessorContext; | |
| 7 | import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter; | |
| 8 | import com.vladsch.flexmark.html.AttributeProvider; | |
| 9 | import com.vladsch.flexmark.html.AttributeProviderFactory; | |
| 10 | import com.vladsch.flexmark.html.IndependentAttributeProviderFactory; | |
| 11 | import com.vladsch.flexmark.html.renderer.AttributablePart; | |
| 12 | import com.vladsch.flexmark.html.renderer.LinkResolverContext; | |
| 13 | import com.vladsch.flexmark.util.ast.Node; | |
| 14 | import com.vladsch.flexmark.util.html.AttributeImpl; | |
| 15 | import com.vladsch.flexmark.util.html.MutableAttributes; | |
| 16 | import org.jetbrains.annotations.NotNull; | |
| 17 | ||
| 18 | import static com.keenwrite.Constants.CARET_ID; | |
| 19 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 20 | ||
| 21 | /** | |
| 22 | * Responsible for giving most block-level elements a unique identifier | |
| 23 | * attribute. The identifier is used to coordinate scrolling. | |
| 24 | */ | |
| 25 | public class CaretExtension extends HtmlRendererAdapter { | |
| 26 | ||
| 27 | private final Caret mCaret; | |
| 28 | ||
| 29 | private CaretExtension( final ProcessorContext context ) { | |
| 30 | mCaret = context.getCaret(); | |
| 31 | } | |
| 32 | ||
| 33 | public static CaretExtension create( final ProcessorContext context ) { | |
| 34 | return new CaretExtension( context ); | |
| 35 | } | |
| 36 | ||
| 37 | @Override | |
| 38 | public void extend( | |
| 39 | final Builder builder, @NotNull final String rendererType ) { | |
| 40 | builder.attributeProviderFactory( | |
| 41 | IdAttributeProvider.createFactory( mCaret ) ); | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Responsible for creating the id attribute. This class is instantiated | |
| 46 | * once: for the HTML element containing the {@link Constants#CARET_ID}. | |
| 47 | */ | |
| 48 | public static class IdAttributeProvider implements AttributeProvider { | |
| 49 | private final Caret mCaret; | |
| 50 | ||
| 51 | public IdAttributeProvider( final Caret caret ) { | |
| 52 | mCaret = caret; | |
| 53 | } | |
| 54 | ||
| 55 | private static AttributeProviderFactory createFactory( | |
| 56 | final Caret caret ) { | |
| 57 | return new IndependentAttributeProviderFactory() { | |
| 58 | @Override | |
| 59 | public @NotNull AttributeProvider apply( | |
| 60 | @NotNull final LinkResolverContext context ) { | |
| 61 | return new IdAttributeProvider( caret ); | |
| 62 | } | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | @Override | |
| 67 | public void setAttributes( @NotNull Node curr, | |
| 68 | @NotNull AttributablePart part, | |
| 69 | @NotNull MutableAttributes attributes ) { | |
| 70 | final var outside = mCaret.isAfterText() ? 1 : 0; | |
| 71 | final var began = curr.getStartOffset(); | |
| 72 | final var ended = curr.getEndOffset() + outside; | |
| 73 | final var prev = curr.getPrevious(); | |
| 74 | ||
| 75 | // If the caret is within the bounds of the current node or the | |
| 76 | // caret is within the bounds of the end of the previous node and | |
| 77 | // the start of the current node, then mark the current node with | |
| 78 | // a caret indicator. | |
| 79 | if( mCaret.isBetweenText( began, ended ) || | |
| 80 | prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) { | |
| 81 | // This line empowers synchronizing the text editor with the preview. | |
| 82 | attributes.addValue( AttributeImpl.of( "id", CARET_ID ) ); | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
| 86 | } | |
| 1 | 87 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.r; | |
| 3 | ||
| 4 | import com.keenwrite.processors.*; | |
| 5 | import com.keenwrite.sigils.RSigilOperator; | |
| 6 | import com.vladsch.flexmark.ast.Text; | |
| 7 | import com.vladsch.flexmark.parser.InlineParserExtensionFactory; | |
| 8 | import com.vladsch.flexmark.parser.InlineParserFactory; | |
| 9 | import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor; | |
| 10 | import com.vladsch.flexmark.parser.internal.InlineParserImpl; | |
| 11 | import com.vladsch.flexmark.parser.internal.LinkRefProcessorData; | |
| 12 | import com.vladsch.flexmark.util.data.DataHolder; | |
| 13 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 14 | ||
| 15 | import java.util.BitSet; | |
| 16 | import java.util.List; | |
| 17 | import java.util.Map; | |
| 18 | ||
| 19 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 20 | import static com.vladsch.flexmark.parser.Parser.Builder; | |
| 21 | import static com.vladsch.flexmark.parser.Parser.ParserExtension; | |
| 22 | ||
| 23 | /** | |
| 24 | * Responsible for processing inline R statements (denoted using the | |
| 25 | * {@link RSigilOperator#PREFIX}) to prevent them from being converted to | |
| 26 | * HTML {@code <code>} elements and stop them from interfering with TeX | |
| 27 | * statements. Note that TeX statements are processed using a Markdown | |
| 28 | * extension, rather than an implementation of {@link Processor}. For this | |
| 29 | * reason, some pre-conversion is necessary. | |
| 30 | */ | |
| 31 | public final class RExtension implements ParserExtension { | |
| 32 | private final InlineParserFactory FACTORY = CustomParser::new; | |
| 33 | ||
| 34 | private final Processor<String> mProcessor; | |
| 35 | private final InlineRProcessor mInlineRProcessor; | |
| 36 | private boolean mReady; | |
| 37 | ||
| 38 | private RExtension( final ProcessorContext context ) { | |
| 39 | final var irp = new InlineRProcessor( IDENTITY, context ); | |
| 40 | final var rvp = new RVariableProcessor( irp, context ); | |
| 41 | mProcessor = new ExecutorProcessor<>( rvp ); | |
| 42 | mInlineRProcessor = irp; | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Creates an extension capable of intercepting R code blocks and preventing | |
| 47 | * them from being converted into HTML {@code <code>} elements. | |
| 48 | */ | |
| 49 | public static RExtension create( final ProcessorContext context ) { | |
| 50 | return new RExtension( context ); | |
| 51 | } | |
| 52 | ||
| 53 | @Override | |
| 54 | public void extend( final Builder builder ) { | |
| 55 | builder.customInlineParserFactory( FACTORY ); | |
| 56 | } | |
| 57 | ||
| 58 | @Override | |
| 59 | public void parserOptions( final MutableDataHolder options ) { | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Prevents rendering {@code `r} statements as inline HTML {@code <code>} | |
| 64 | * blocks, which allows the {@link InlineRProcessor} to post-process the | |
| 65 | * text prior to display in the preview pane. This intervention assists | |
| 66 | * with decoupling the caret from the Markdown content so that the two | |
| 67 | * can vary independently in the architecture while permitting synchronization | |
| 68 | * of the editor and preview pane. | |
| 69 | * <p> | |
| 70 | * The text is therefore processed twice: once by flexmark-java and once by | |
| 71 | * {@link InlineRProcessor}. | |
| 72 | * </p> | |
| 73 | */ | |
| 74 | private class CustomParser extends InlineParserImpl { | |
| 75 | private CustomParser( | |
| 76 | final DataHolder options, | |
| 77 | final BitSet specialCharacters, | |
| 78 | final BitSet delimiterCharacters, | |
| 79 | final Map<Character, DelimiterProcessor> delimiterProcessors, | |
| 80 | final LinkRefProcessorData referenceLinkProcessors, | |
| 81 | final List<InlineParserExtensionFactory> inlineParserExtensions ) { | |
| 82 | super( options, | |
| 83 | specialCharacters, | |
| 84 | delimiterCharacters, | |
| 85 | delimiterProcessors, | |
| 86 | referenceLinkProcessors, | |
| 87 | inlineParserExtensions ); | |
| 88 | mReady = mInlineRProcessor.init(); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * The superclass handles a number backtick parsing edge cases; this method | |
| 93 | * changes the behaviour to retain R code snippets, identified by | |
| 94 | * {@link RSigilOperator#PREFIX}, so that subsequent processing can | |
| 95 | * invoke R. If other languages are added, the {@link CustomParser} will | |
| 96 | * have to be rewritten to identify more than merely R. | |
| 97 | * | |
| 98 | * @return The return value from {@link super#parseBackticks()}. | |
| 99 | * @inheritDoc | |
| 100 | */ | |
| 101 | @Override | |
| 102 | protected final boolean parseBackticks() { | |
| 103 | final var foundTicks = super.parseBackticks(); | |
| 104 | ||
| 105 | if( foundTicks && mReady ) { | |
| 106 | final var blockNode = getBlock(); | |
| 107 | final var codeNode = blockNode.getLastChild(); | |
| 108 | ||
| 109 | if( codeNode != null ) { | |
| 110 | final var code = codeNode.getChars().toString(); | |
| 111 | ||
| 112 | if( code.startsWith( RSigilOperator.PREFIX ) ) { | |
| 113 | codeNode.unlink(); | |
| 114 | blockNode.appendChild( new Text( mProcessor.apply( code ) ) ); | |
| 115 | } | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | return foundTicks; | |
| 120 | } | |
| 121 | } | |
| 122 | } | |
| 1 | 123 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.r; | |
| 3 | ||
| 4 | import com.keenwrite.processors.ExecutorProcessor; | |
| 5 | import com.keenwrite.processors.InlineRProcessor; | |
| 6 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 7 | import com.keenwrite.processors.markdown.extensions.tex.TeXExtension; | |
| 8 | import com.vladsch.flexmark.ast.Paragraph; | |
| 9 | import com.vladsch.flexmark.ast.Text; | |
| 10 | import com.vladsch.flexmark.html.HtmlRenderer; | |
| 11 | import com.vladsch.flexmark.parser.Parser; | |
| 12 | import com.vladsch.flexmark.util.ast.IParse; | |
| 13 | import com.vladsch.flexmark.util.ast.IRender; | |
| 14 | ||
| 15 | /** | |
| 16 | * Responsible for parsing the output from an R eval statement. This class | |
| 17 | * is used to avoid an circular dependency whereby the {@link InlineRProcessor} | |
| 18 | * must treat the output from an R function call as Markdown, which would | |
| 19 | * otherwise require a {@link MarkdownProcessor} instance; however, the | |
| 20 | * {@link MarkdownProcessor} class gives precedence to its extensions, which | |
| 21 | * means the {@link TeXExtension} will be executed <em>before</em> the | |
| 22 | * {@link InlineRProcessor}, thereby being exposed to backticks in a TeX | |
| 23 | * macro---a syntax error. To break the cycle, the {@link InlineRProcessor} | |
| 24 | * uses this class instead of {@link MarkdownProcessor}. | |
| 25 | */ | |
| 26 | public class ROutputProcessor extends ExecutorProcessor<String> { | |
| 27 | private final IParse mParser = Parser.builder().build(); | |
| 28 | private final IRender mRenderer = HtmlRenderer.builder().build(); | |
| 29 | ||
| 30 | @Override | |
| 31 | public String apply( final String markdown ) { | |
| 32 | var node = mParser.parse( markdown ).getFirstChild(); | |
| 33 | ||
| 34 | if( node == null ) { | |
| 35 | node = new Text(); | |
| 36 | } | |
| 37 | else if( node.isOrDescendantOfType( Paragraph.class ) ) { | |
| 38 | node = new Text( node.getChars() ); | |
| 39 | } | |
| 40 | ||
| 41 | // Trimming prevents displaced commas and unwanted newlines. | |
| 42 | return mRenderer.render( node ).trim(); | |
| 43 | } | |
| 44 | } | |
| 1 | 45 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.tex; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.processors.ProcessorContext; | |
| 6 | import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter; | |
| 7 | import com.keenwrite.processors.markdown.extensions.tex.TexNodeRenderer.Factory; | |
| 8 | import com.vladsch.flexmark.html.HtmlRenderer; | |
| 9 | import com.vladsch.flexmark.parser.Parser; | |
| 10 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 11 | import org.jetbrains.annotations.NotNull; | |
| 12 | ||
| 13 | import static com.vladsch.flexmark.parser.Parser.ParserExtension; | |
| 14 | ||
| 15 | /** | |
| 16 | * Responsible for wrapping delimited TeX code in Markdown into an XML element | |
| 17 | * that the HTML renderer can handle. For example, {@code $E=mc^2$} becomes | |
| 18 | * {@code <tex>E=mc^2</tex>} when passed to HTML renderer. The HTML renderer | |
| 19 | * is responsible for converting the TeX code for display. This avoids inserting | |
| 20 | * SVG code into the Markdown document, which the parser would then have to | |
| 21 | * iterate---a <em>very</em> wasteful operation that impacts front-end | |
| 22 | * performance. | |
| 23 | */ | |
| 24 | public class TeXExtension extends HtmlRendererAdapter | |
| 25 | implements ParserExtension { | |
| 26 | ||
| 27 | /** | |
| 28 | * Controls how the node renderer produces TeX code within HTML output. | |
| 29 | */ | |
| 30 | private final ExportFormat mExportFormat; | |
| 31 | ||
| 32 | private TeXExtension( final ProcessorContext context ) { | |
| 33 | mExportFormat = context.getExportFormat(); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Creates an extension capable of handling delimited TeX code in Markdown. | |
| 38 | * | |
| 39 | * @return The new {@link TeXExtension}, never {@code null}. | |
| 40 | */ | |
| 41 | public static TeXExtension create( final ProcessorContext context ) { | |
| 42 | return new TeXExtension( context ); | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Adds the TeX extension for HTML document export types. | |
| 47 | * | |
| 48 | * @param builder The document builder. | |
| 49 | * @param rendererType Indicates the document type to be built. | |
| 50 | */ | |
| 51 | @Override | |
| 52 | public void extend( @NotNull final HtmlRenderer.Builder builder, | |
| 53 | @NotNull final String rendererType ) { | |
| 54 | if( "HTML".equalsIgnoreCase( rendererType ) ) { | |
| 55 | builder.nodeRendererFactory( new Factory( mExportFormat ) ); | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | @Override | |
| 60 | public void extend( final Parser.Builder builder ) { | |
| 61 | builder.customDelimiterProcessor( new TeXInlineDelimiterProcessor() ); | |
| 62 | } | |
| 63 | ||
| 64 | @Override | |
| 65 | public void parserOptions( final MutableDataHolder options ) { | |
| 66 | } | |
| 67 | } | |
| 1 | 68 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.tex; | |
| 3 | ||
| 4 | import com.vladsch.flexmark.parser.InlineParser; | |
| 5 | import com.vladsch.flexmark.parser.core.delimiter.Delimiter; | |
| 6 | import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor; | |
| 7 | import com.vladsch.flexmark.parser.delimiter.DelimiterRun; | |
| 8 | import com.vladsch.flexmark.util.ast.Node; | |
| 9 | ||
| 10 | public class TeXInlineDelimiterProcessor implements DelimiterProcessor { | |
| 11 | ||
| 12 | @Override | |
| 13 | public void process( final Delimiter opener, | |
| 14 | final Delimiter closer, | |
| 15 | final int delimitersUsed ) { | |
| 16 | final var node = new TexNode(); | |
| 17 | opener.moveNodesBetweenDelimitersTo( node, closer ); | |
| 18 | } | |
| 19 | ||
| 20 | @Override | |
| 21 | public char getOpeningCharacter() { | |
| 22 | return '$'; | |
| 23 | } | |
| 24 | ||
| 25 | @Override | |
| 26 | public char getClosingCharacter() { | |
| 27 | return '$'; | |
| 28 | } | |
| 29 | ||
| 30 | @Override | |
| 31 | public int getMinLength() { | |
| 32 | return 1; | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * Allow for $ or $$. | |
| 37 | * | |
| 38 | * @param opener One or more opening delimiter characters. | |
| 39 | * @param closer One or more closing delimiter characters. | |
| 40 | * @return The number of delimiters to use to determine whether a valid | |
| 41 | * opening delimiter expression is found. | |
| 42 | */ | |
| 43 | @Override | |
| 44 | public int getDelimiterUse( | |
| 45 | final DelimiterRun opener, final DelimiterRun closer ) { | |
| 46 | return 1; | |
| 47 | } | |
| 48 | ||
| 49 | @Override | |
| 50 | public boolean canBeOpener( final String before, | |
| 51 | final String after, | |
| 52 | final boolean leftFlanking, | |
| 53 | final boolean rightFlanking, | |
| 54 | final boolean beforeIsPunctuation, | |
| 55 | final boolean afterIsPunctuation, | |
| 56 | final boolean beforeIsWhitespace, | |
| 57 | final boolean afterIsWhiteSpace ) { | |
| 58 | return leftFlanking; | |
| 59 | } | |
| 60 | ||
| 61 | @Override | |
| 62 | public boolean canBeCloser( final String before, | |
| 63 | final String after, | |
| 64 | final boolean leftFlanking, | |
| 65 | final boolean rightFlanking, | |
| 66 | final boolean beforeIsPunctuation, | |
| 67 | final boolean afterIsPunctuation, | |
| 68 | final boolean beforeIsWhitespace, | |
| 69 | final boolean afterIsWhiteSpace ) { | |
| 70 | return rightFlanking; | |
| 71 | } | |
| 72 | ||
| 73 | @Override | |
| 74 | public Node unmatchedDelimiterNode( | |
| 75 | final InlineParser inlineParser, final DelimiterRun delimiter ) { | |
| 76 | return null; | |
| 77 | } | |
| 78 | ||
| 79 | @Override | |
| 80 | public boolean skipNonOpenerCloser() { | |
| 81 | return false; | |
| 82 | } | |
| 83 | } | |
| 1 | 84 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.tex; | |
| 3 | ||
| 4 | import com.vladsch.flexmark.ast.DelimitedNodeImpl; | |
| 5 | ||
| 6 | public class TexNode extends DelimitedNodeImpl { | |
| 7 | /** | |
| 8 | * TeX expression wrapped in a {@code <tex>} element. | |
| 9 | */ | |
| 10 | public static final String HTML_TEX = "tex"; | |
| 11 | ||
| 12 | public static final String TOKEN_OPEN = "$"; | |
| 13 | public static final String TOKEN_CLOSE = "$"; | |
| 14 | ||
| 15 | public TexNode() { | |
| 16 | } | |
| 17 | } | |
| 1 | 18 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.tex; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.preview.SvgRasterizer; | |
| 6 | import com.vladsch.flexmark.html.HtmlWriter; | |
| 7 | import com.vladsch.flexmark.html.renderer.NodeRenderer; | |
| 8 | import com.vladsch.flexmark.html.renderer.NodeRendererContext; | |
| 9 | import com.vladsch.flexmark.html.renderer.NodeRendererFactory; | |
| 10 | import com.vladsch.flexmark.html.renderer.NodeRenderingHandler; | |
| 11 | import com.vladsch.flexmark.util.ast.Node; | |
| 12 | import com.vladsch.flexmark.util.data.DataHolder; | |
| 13 | import org.jetbrains.annotations.NotNull; | |
| 14 | import org.jetbrains.annotations.Nullable; | |
| 15 | ||
| 16 | import java.util.Set; | |
| 17 | ||
| 18 | import static com.keenwrite.preview.MathRenderer.MATH_RENDERER; | |
| 19 | import static com.keenwrite.processors.markdown.extensions.tex.TexNode.*; | |
| 20 | ||
| 21 | public class TexNodeRenderer { | |
| 22 | ||
| 23 | public static class Factory implements NodeRendererFactory { | |
| 24 | private final ExportFormat mExportFormat; | |
| 25 | ||
| 26 | public Factory( final ExportFormat exportFormat ) { | |
| 27 | mExportFormat = exportFormat; | |
| 28 | } | |
| 29 | ||
| 30 | @NotNull | |
| 31 | @Override | |
| 32 | public NodeRenderer apply( @NotNull DataHolder options ) { | |
| 33 | return switch( mExportFormat ) { | |
| 34 | case HTML_TEX_SVG -> new TexSvgNodeRenderer(); | |
| 35 | case HTML_TEX_DELIMITED, MARKDOWN_PLAIN -> new TexDelimNodeRenderer(); | |
| 36 | case NONE -> new TexElementNodeRenderer(); | |
| 37 | }; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | private static abstract class AbstractTexNodeRenderer | |
| 42 | implements NodeRenderer { | |
| 43 | ||
| 44 | @Override | |
| 45 | public @Nullable Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() { | |
| 46 | final var h = new NodeRenderingHandler<>( TexNode.class, this::render ); | |
| 47 | return Set.of( h ); | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Subclasses implement this method to render the content of {@link TexNode} | |
| 52 | * instances as per their associated {@link ExportFormat}. | |
| 53 | * | |
| 54 | * @param node {@link Node} containing text content of a math formula. | |
| 55 | * @param context Configuration information (unused). | |
| 56 | * @param html Where to write the rendered output. | |
| 57 | */ | |
| 58 | abstract void render( final TexNode node, | |
| 59 | final NodeRendererContext context, | |
| 60 | final HtmlWriter html ); | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Responsible for rendering a TeX node as an HTML {@code <tex>} | |
| 65 | * element. This is the default behaviour. | |
| 66 | */ | |
| 67 | private static class TexElementNodeRenderer extends AbstractTexNodeRenderer { | |
| 68 | void render( final TexNode node, | |
| 69 | final NodeRendererContext context, | |
| 70 | final HtmlWriter html ) { | |
| 71 | html.tag( HTML_TEX ); | |
| 72 | html.raw( node.getText() ); | |
| 73 | html.closeTag( HTML_TEX ); | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Responsible for rendering a TeX node as an HTML {@code <svg>} | |
| 79 | * element. | |
| 80 | */ | |
| 81 | private static class TexSvgNodeRenderer extends AbstractTexNodeRenderer { | |
| 82 | void render( final TexNode node, | |
| 83 | final NodeRendererContext context, | |
| 84 | final HtmlWriter html ) { | |
| 85 | final var tex = node.getText().toStringOrNull(); | |
| 86 | final var doc = MATH_RENDERER.render( tex == null ? "" : tex ); | |
| 87 | final var svg = SvgRasterizer.toSvg( doc.getDocumentElement() ); | |
| 88 | html.raw( svg ); | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Responsible for rendering a TeX node as text bracketed by $ tokens. | |
| 94 | */ | |
| 95 | private static class TexDelimNodeRenderer extends AbstractTexNodeRenderer { | |
| 96 | void render( final TexNode node, | |
| 97 | final NodeRendererContext context, | |
| 98 | final HtmlWriter html ) { | |
| 99 | html.raw( TOKEN_OPEN ); | |
| 100 | html.raw( node.getText() ); | |
| 101 | html.raw( TOKEN_CLOSE ); | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 1 | 105 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.r; | |
| 3 | ||
| 4 | import com.keenwrite.processors.InlineRProcessor; | |
| 5 | import com.keenwrite.sigils.RSigilOperator; | |
| 6 | import com.vladsch.flexmark.ast.Text; | |
| 7 | import com.vladsch.flexmark.parser.InlineParserExtensionFactory; | |
| 8 | import com.vladsch.flexmark.parser.InlineParserFactory; | |
| 9 | import com.vladsch.flexmark.parser.Parser; | |
| 10 | import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor; | |
| 11 | import com.vladsch.flexmark.parser.internal.InlineParserImpl; | |
| 12 | import com.vladsch.flexmark.parser.internal.LinkRefProcessorData; | |
| 13 | import com.vladsch.flexmark.util.data.DataHolder; | |
| 14 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 15 | ||
| 16 | import java.util.BitSet; | |
| 17 | import java.util.List; | |
| 18 | import java.util.Map; | |
| 19 | ||
| 20 | /** | |
| 21 | * Responsible for preventing the Markdown engine from interpreting inline | |
| 22 | * backticks as inline code elements. This is required so that inline R code | |
| 23 | * can be executed after conversion of Markdown to HTML but before the HTML | |
| 24 | * is previewed (or exported). | |
| 25 | */ | |
| 26 | public final class RExtension implements Parser.ParserExtension { | |
| 27 | private static final InlineParserFactory FACTORY = CustomParser::new; | |
| 28 | ||
| 29 | private RExtension() { | |
| 30 | } | |
| 31 | ||
| 32 | /** | |
| 33 | * Creates an extension capable of intercepting R code blocks and preventing | |
| 34 | * them from being converted into HTML {@code <code>} elements. | |
| 35 | */ | |
| 36 | public static RExtension create() { | |
| 37 | return new RExtension(); | |
| 38 | } | |
| 39 | ||
| 40 | @Override | |
| 41 | public void extend( final Parser.Builder builder ) { | |
| 42 | builder.customInlineParserFactory( FACTORY ); | |
| 43 | } | |
| 44 | ||
| 45 | @Override | |
| 46 | public void parserOptions( final MutableDataHolder options ) { | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Prevents rendering {@code `r} statements as inline HTML {@code <code>} | |
| 51 | * blocks, which allows the {@link InlineRProcessor} to post-process the | |
| 52 | * text prior to display in the preview pane. This intervention assists | |
| 53 | * with decoupling the caret from the Markdown content so that the two | |
| 54 | * can vary independently in the architecture while permitting synchronization | |
| 55 | * of the editor and preview pane. | |
| 56 | * <p> | |
| 57 | * The text is therefore processed twice: once by flexmark-java and once by | |
| 58 | * {@link InlineRProcessor}. | |
| 59 | * </p> | |
| 60 | */ | |
| 61 | private static class CustomParser extends InlineParserImpl { | |
| 62 | private CustomParser( | |
| 63 | final DataHolder options, | |
| 64 | final BitSet specialCharacters, | |
| 65 | final BitSet delimiterCharacters, | |
| 66 | final Map<Character, DelimiterProcessor> delimiterProcessors, | |
| 67 | final LinkRefProcessorData referenceLinkProcessors, | |
| 68 | final List<InlineParserExtensionFactory> inlineParserExtensions ) { | |
| 69 | super( options, | |
| 70 | specialCharacters, | |
| 71 | delimiterCharacters, | |
| 72 | delimiterProcessors, | |
| 73 | referenceLinkProcessors, | |
| 74 | inlineParserExtensions ); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * The superclass handles a number backtick parsing edge cases; this method | |
| 79 | * changes the behaviour to retain R code snippets, identified by | |
| 80 | * {@link RSigilOperator#PREFIX}, so that subsequent processing can | |
| 81 | * invoke R. If other languages are added, the {@link CustomParser} will | |
| 82 | * have to be rewritten to identify more than merely R. | |
| 83 | * | |
| 84 | * @return The return value from {@link super#parseBackticks()}. | |
| 85 | * @inheritDoc | |
| 86 | */ | |
| 87 | @Override | |
| 88 | protected final boolean parseBackticks() { | |
| 89 | final var foundTicks = super.parseBackticks(); | |
| 90 | ||
| 91 | if( foundTicks ) { | |
| 92 | final var blockNode = getBlock(); | |
| 93 | final var codeNode = blockNode.getLastChild(); | |
| 94 | ||
| 95 | if( codeNode != null ) { | |
| 96 | final var code = codeNode.getChars(); | |
| 97 | ||
| 98 | if( code.startsWith( RSigilOperator.PREFIX ) ) { | |
| 99 | codeNode.unlink(); | |
| 100 | blockNode.appendChild( new Text( code ) ); | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | return foundTicks; | |
| 106 | } | |
| 107 | } | |
| 108 | } | |
| 109 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.tex; | |
| 3 | ||
| 4 | import com.vladsch.flexmark.parser.InlineParser; | |
| 5 | import com.vladsch.flexmark.parser.core.delimiter.Delimiter; | |
| 6 | import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor; | |
| 7 | import com.vladsch.flexmark.parser.delimiter.DelimiterRun; | |
| 8 | import com.vladsch.flexmark.util.ast.Node; | |
| 9 | ||
| 10 | public class TeXInlineDelimiterProcessor implements DelimiterProcessor { | |
| 11 | ||
| 12 | @Override | |
| 13 | public void process( final Delimiter opener, | |
| 14 | final Delimiter closer, | |
| 15 | final int delimitersUsed ) { | |
| 16 | final var node = new TexNode(); | |
| 17 | opener.moveNodesBetweenDelimitersTo( node, closer ); | |
| 18 | } | |
| 19 | ||
| 20 | @Override | |
| 21 | public char getOpeningCharacter() { | |
| 22 | return '$'; | |
| 23 | } | |
| 24 | ||
| 25 | @Override | |
| 26 | public char getClosingCharacter() { | |
| 27 | return '$'; | |
| 28 | } | |
| 29 | ||
| 30 | @Override | |
| 31 | public int getMinLength() { | |
| 32 | return 1; | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * Allow for $ or $$. | |
| 37 | * | |
| 38 | * @param opener One or more opening delimiter characters. | |
| 39 | * @param closer One or more closing delimiter characters. | |
| 40 | * @return The number of delimiters to use to determine whether a valid | |
| 41 | * opening delimiter expression is found. | |
| 42 | */ | |
| 43 | @Override | |
| 44 | public int getDelimiterUse( | |
| 45 | final DelimiterRun opener, final DelimiterRun closer ) { | |
| 46 | return 1; | |
| 47 | } | |
| 48 | ||
| 49 | @Override | |
| 50 | public boolean canBeOpener( final String before, | |
| 51 | final String after, | |
| 52 | final boolean leftFlanking, | |
| 53 | final boolean rightFlanking, | |
| 54 | final boolean beforeIsPunctuation, | |
| 55 | final boolean afterIsPunctuation, | |
| 56 | final boolean beforeIsWhitespace, | |
| 57 | final boolean afterIsWhiteSpace ) { | |
| 58 | return leftFlanking; | |
| 59 | } | |
| 60 | ||
| 61 | @Override | |
| 62 | public boolean canBeCloser( final String before, | |
| 63 | final String after, | |
| 64 | final boolean leftFlanking, | |
| 65 | final boolean rightFlanking, | |
| 66 | final boolean beforeIsPunctuation, | |
| 67 | final boolean afterIsPunctuation, | |
| 68 | final boolean beforeIsWhitespace, | |
| 69 | final boolean afterIsWhiteSpace ) { | |
| 70 | return rightFlanking; | |
| 71 | } | |
| 72 | ||
| 73 | @Override | |
| 74 | public Node unmatchedDelimiterNode( | |
| 75 | final InlineParser inlineParser, final DelimiterRun delimiter ) { | |
| 76 | return null; | |
| 77 | } | |
| 78 | ||
| 79 | @Override | |
| 80 | public boolean skipNonOpenerCloser() { | |
| 81 | return false; | |
| 82 | } | |
| 83 | } | |
| 84 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.tex; | |
| 3 | ||
| 4 | import com.vladsch.flexmark.ast.DelimitedNodeImpl; | |
| 5 | ||
| 6 | public class TexNode extends DelimitedNodeImpl { | |
| 7 | /** | |
| 8 | * TeX expression wrapped in a {@code <tex>} element. | |
| 9 | */ | |
| 10 | public static final String HTML_TEX = "tex"; | |
| 11 | ||
| 12 | public static final String TOKEN_OPEN = "$"; | |
| 13 | public static final String TOKEN_CLOSE = "$"; | |
| 14 | ||
| 15 | public TexNode() { | |
| 16 | } | |
| 17 | } | |
| 18 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.tex; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.preview.SvgRasterizer; | |
| 6 | import com.vladsch.flexmark.html.HtmlWriter; | |
| 7 | import com.vladsch.flexmark.html.renderer.NodeRenderer; | |
| 8 | import com.vladsch.flexmark.html.renderer.NodeRendererContext; | |
| 9 | import com.vladsch.flexmark.html.renderer.NodeRendererFactory; | |
| 10 | import com.vladsch.flexmark.html.renderer.NodeRenderingHandler; | |
| 11 | import com.vladsch.flexmark.util.ast.Node; | |
| 12 | import com.vladsch.flexmark.util.data.DataHolder; | |
| 13 | import org.jetbrains.annotations.NotNull; | |
| 14 | import org.jetbrains.annotations.Nullable; | |
| 15 | ||
| 16 | import java.util.Set; | |
| 17 | ||
| 18 | import static com.keenwrite.preview.MathRenderer.MATH_RENDERER; | |
| 19 | import static com.keenwrite.processors.markdown.tex.TexNode.*; | |
| 20 | ||
| 21 | public class TexNodeRenderer { | |
| 22 | ||
| 23 | public static class Factory implements NodeRendererFactory { | |
| 24 | private final ExportFormat mExportFormat; | |
| 25 | ||
| 26 | public Factory( final ExportFormat exportFormat ) { | |
| 27 | mExportFormat = exportFormat; | |
| 28 | } | |
| 29 | ||
| 30 | @NotNull | |
| 31 | @Override | |
| 32 | public NodeRenderer apply( @NotNull DataHolder options ) { | |
| 33 | return switch( mExportFormat ) { | |
| 34 | case HTML_TEX_SVG -> new TexSvgNodeRenderer(); | |
| 35 | case HTML_TEX_DELIMITED, MARKDOWN_PLAIN -> new TexDelimNodeRenderer(); | |
| 36 | case NONE -> new TexElementNodeRenderer(); | |
| 37 | }; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | private static abstract class AbstractTexNodeRenderer | |
| 42 | implements NodeRenderer { | |
| 43 | ||
| 44 | @Override | |
| 45 | public @Nullable Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() { | |
| 46 | final var h = new NodeRenderingHandler<>( TexNode.class, this::render ); | |
| 47 | return Set.of( h ); | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Subclasses implement this method to render the content of {@link TexNode} | |
| 52 | * instances as per their associated {@link ExportFormat}. | |
| 53 | * | |
| 54 | * @param node {@link Node} containing text content of a math formula. | |
| 55 | * @param context Configuration information (unused). | |
| 56 | * @param html Where to write the rendered output. | |
| 57 | */ | |
| 58 | abstract void render( final TexNode node, | |
| 59 | final NodeRendererContext context, | |
| 60 | final HtmlWriter html ); | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Responsible for rendering a TeX node as an HTML {@code <tex>} | |
| 65 | * element. This is the default behaviour. | |
| 66 | */ | |
| 67 | private static class TexElementNodeRenderer extends AbstractTexNodeRenderer { | |
| 68 | void render( final TexNode node, | |
| 69 | final NodeRendererContext context, | |
| 70 | final HtmlWriter html ) { | |
| 71 | html.tag( HTML_TEX ); | |
| 72 | html.raw( node.getText() ); | |
| 73 | html.closeTag( HTML_TEX ); | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Responsible for rendering a TeX node as an HTML {@code <svg>} | |
| 79 | * element. | |
| 80 | */ | |
| 81 | private static class TexSvgNodeRenderer extends AbstractTexNodeRenderer { | |
| 82 | void render( final TexNode node, | |
| 83 | final NodeRendererContext context, | |
| 84 | final HtmlWriter html ) { | |
| 85 | final var tex = node.getText().toStringOrNull(); | |
| 86 | final var doc = MATH_RENDERER.render( tex == null ? "" : tex ); | |
| 87 | final var svg = SvgRasterizer.toSvg( doc.getDocumentElement() ); | |
| 88 | html.raw( svg ); | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Responsible for rendering a TeX node as text bracketed by $ tokens. | |
| 94 | */ | |
| 95 | private static class TexDelimNodeRenderer extends AbstractTexNodeRenderer { | |
| 96 | void render( final TexNode node, | |
| 97 | final NodeRendererContext context, | |
| 98 | final HtmlWriter html ) { | |
| 99 | html.raw( TOKEN_OPEN ); | |
| 100 | html.raw( node.getText() ); | |
| 101 | html.raw( TOKEN_CLOSE ); | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors.text; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors.text; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors.text; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors.text; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors.text; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.search; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service.events; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service.events; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service.events.impl; |
| 3 | 3 | |
| ... | ||
| 14 | 14 | */ |
| 15 | 15 | public class ButtonOrderPane extends DialogPane { |
| 16 | public ButtonOrderPane() { | |
| 17 | } | |
| 16 | 18 | |
| 17 | 19 | @Override |
| 18 | 20 | protected Node createButtonBar() { |
| 19 | 21 | final var node = (ButtonBar) super.createButtonBar(); |
| 20 | 22 | node.setButtonOrder( getButtonOrder() ); |
| 21 | 23 | return node; |
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | private String getButtonOrder() { |
| 25 | return getSetting( "dialog.alert.button.order.windows", | |
| 26 | BUTTON_ORDER_WINDOWS ); | |
| 27 | } | |
| 28 | ||
| 29 | @SuppressWarnings("SameParameterValue") | |
| 30 | private String getSetting( final String key, final String defaultValue ) { | |
| 31 | return sSettings.getSetting( key, defaultValue ); | |
| 27 | return sSettings.getSetting( | |
| 28 | "dialog.alert.button.order.windows", BUTTON_ORDER_WINDOWS ); | |
| 32 | 29 | } |
| 33 | 30 | } |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service.events.impl; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service.events.impl; |
| 3 | 3 | |
| ... | ||
| 10 | 10 | import java.nio.file.Path; |
| 11 | 11 | |
| 12 | import static com.keenwrite.Constants.ICON_DIALOG_NODE; | |
| 12 | 13 | import static com.keenwrite.Messages.get; |
| 13 | 14 | import static javafx.scene.control.Alert.AlertType.CONFIRMATION; |
| ... | ||
| 68 | 69 | alert.setContentText( message.getContent() ); |
| 69 | 70 | alert.initOwner( parent ); |
| 71 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 70 | 72 | |
| 71 | 73 | return alert; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service.impl; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.service.impl; |
| 3 | 3 | |
| ... | ||
| 13 | 13 | |
| 14 | 14 | import static com.keenwrite.Constants.APP_WATCHDOG_TIMEOUT; |
| 15 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 15 | import static com.keenwrite.StatusNotifier.clue; | |
| 16 | 16 | import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; |
| 17 | 17 | |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.sigils; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.sigils; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.sigils; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.sigils; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.spelling.api; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.spelling.api; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | 2 | * |
| 3 | 3 | * Redistribution and use in source and binary forms, with or without |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.spelling.impl; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.spelling.impl; |
| 3 | 3 | |
| ... | ||
| 18 | 18 | |
| 19 | 19 | import static com.keenwrite.Constants.LEXICONS_DIRECTORY; |
| 20 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 20 | import static com.keenwrite.StatusNotifier.clue; | |
| 21 | 21 | import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity; |
| 22 | 22 | import static io.gitlab.rxp90.jsymspell.SymSpell.Verbosity.ALL; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.spelling.impl; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. | |
| 2 | 2 | * |
| 3 | 3 | * All rights reserved. |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.ui.actions; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.actions; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.MainPane; | |
| 6 | import com.keenwrite.editors.TextDefinition; | |
| 7 | import com.keenwrite.editors.TextEditor; | |
| 8 | import com.keenwrite.preferences.PreferencesController; | |
| 9 | import com.keenwrite.preferences.Workspace; | |
| 10 | import com.keenwrite.processors.ProcessorContext; | |
| 11 | import com.keenwrite.search.SearchModel; | |
| 12 | import com.keenwrite.ui.controls.SearchBar; | |
| 13 | import javafx.scene.control.Alert; | |
| 14 | import javafx.scene.image.ImageView; | |
| 15 | import javafx.stage.Window; | |
| 16 | import javafx.stage.WindowEvent; | |
| 17 | ||
| 18 | import static com.keenwrite.Bootstrap.APP_TITLE; | |
| 19 | import static com.keenwrite.Constants.ICON_DIALOG; | |
| 20 | import static com.keenwrite.ExportFormat.*; | |
| 21 | import static com.keenwrite.Messages.get; | |
| 22 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 23 | import static com.keenwrite.StatusBarNotifier.getStatusBar; | |
| 24 | import static com.keenwrite.preferences.Workspace.KEY_UI_RECENT_DIR; | |
| 25 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 26 | import static java.nio.file.Files.writeString; | |
| 27 | import static javafx.event.Event.fireEvent; | |
| 28 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 29 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 30 | ||
| 31 | /** | |
| 32 | * Responsible for abstracting how functionality is mapped to the application. | |
| 33 | * This allows users to customize accelerator keys and will provide pluggable | |
| 34 | * functionality so that different text markup languages can change documents | |
| 35 | * using their respective syntax. | |
| 36 | */ | |
| 37 | @SuppressWarnings("NonAsciiCharacters") | |
| 38 | public class ApplicationActions { | |
| 39 | private static final String STYLE_SEARCH = "search"; | |
| 40 | ||
| 41 | /** | |
| 42 | * When an action is executed, this is one of the recipients. | |
| 43 | */ | |
| 44 | private final MainPane mMainPane; | |
| 45 | ||
| 46 | /** | |
| 47 | * Tracks finding text in the active document. | |
| 48 | */ | |
| 49 | private final SearchModel mSearchModel; | |
| 50 | ||
| 51 | public ApplicationActions( final MainPane mainPane ) { | |
| 52 | mMainPane = mainPane; | |
| 53 | mSearchModel = new SearchModel(); | |
| 54 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 55 | final var editor = getActiveTextEditor(); | |
| 56 | ||
| 57 | // Clear highlighted areas before adding highlighting to a new region. | |
| 58 | if( o != null ) { | |
| 59 | editor.unstylize( STYLE_SEARCH ); | |
| 60 | } | |
| 61 | ||
| 62 | if( n != null ) { | |
| 63 | editor.moveTo( n.getStart() ); | |
| 64 | editor.stylize( n, STYLE_SEARCH ); | |
| 65 | } | |
| 66 | } ); | |
| 67 | ||
| 68 | // When the active text editor changes, update the haystack. | |
| 69 | mMainPane.activeTextEditorProperty().addListener( | |
| 70 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 71 | ); | |
| 72 | } | |
| 73 | ||
| 74 | public void file‿new() { | |
| 75 | getMainPane().newTextEditor(); | |
| 76 | } | |
| 77 | ||
| 78 | public void file‿open() { | |
| 79 | getMainPane().open( createFileChooser().openFiles() ); | |
| 80 | } | |
| 81 | ||
| 82 | public void file‿close() { | |
| 83 | getMainPane().close(); | |
| 84 | } | |
| 85 | ||
| 86 | public void file‿close_all() { | |
| 87 | getMainPane().closeAll(); | |
| 88 | } | |
| 89 | ||
| 90 | public void file‿save() { | |
| 91 | getMainPane().save(); | |
| 92 | } | |
| 93 | ||
| 94 | public void file‿save_as() { | |
| 95 | final var file = createFileChooser().saveAs(); | |
| 96 | file.ifPresent( ( f ) -> getMainPane().saveAs( f ) ); | |
| 97 | } | |
| 98 | ||
| 99 | public void file‿save_all() { | |
| 100 | getMainPane().saveAll(); | |
| 101 | } | |
| 102 | ||
| 103 | public void file‿export‿html_svg() { | |
| 104 | file‿export( HTML_TEX_SVG ); | |
| 105 | } | |
| 106 | ||
| 107 | public void file‿export‿html_tex() { | |
| 108 | file‿export( HTML_TEX_DELIMITED ); | |
| 109 | } | |
| 110 | ||
| 111 | public void file‿export‿markdown() { | |
| 112 | file‿export( MARKDOWN_PLAIN ); | |
| 113 | } | |
| 114 | ||
| 115 | private void file‿export( final ExportFormat format ) { | |
| 116 | final var editor = getActiveTextEditor(); | |
| 117 | final var context = createProcessorContext( editor ); | |
| 118 | final var chain = createProcessors( context ); | |
| 119 | final var doc = editor.getText(); | |
| 120 | final var export = chain.apply( doc ); | |
| 121 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 122 | final var chooser = createFileChooser(); | |
| 123 | final var file = chooser.exportAs( filename ); | |
| 124 | ||
| 125 | file.ifPresent( ( f ) -> { | |
| 126 | try { | |
| 127 | writeString( f.toPath(), export ); | |
| 128 | final var m = get( "Main.status.export.success", f.toString() ); | |
| 129 | clue( m ); | |
| 130 | } catch( final Exception e ) { | |
| 131 | clue( e ); | |
| 132 | } | |
| 133 | } ); | |
| 134 | } | |
| 135 | ||
| 136 | private ProcessorContext createProcessorContext( final TextEditor editor ) { | |
| 137 | return getMainPane().createProcessorContext( editor ); | |
| 138 | } | |
| 139 | ||
| 140 | public void file‿exit() { | |
| 141 | final var window = getWindow(); | |
| 142 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 143 | } | |
| 144 | ||
| 145 | public void edit‿undo() { | |
| 146 | getActiveTextEditor().undo(); | |
| 147 | } | |
| 148 | ||
| 149 | public void edit‿redo() { | |
| 150 | getActiveTextEditor().redo(); | |
| 151 | } | |
| 152 | ||
| 153 | public void edit‿cut() { | |
| 154 | getActiveTextEditor().cut(); | |
| 155 | } | |
| 156 | ||
| 157 | public void edit‿copy() { | |
| 158 | getActiveTextEditor().copy(); | |
| 159 | } | |
| 160 | ||
| 161 | public void edit‿paste() { | |
| 162 | getActiveTextEditor().paste(); | |
| 163 | } | |
| 164 | ||
| 165 | public void edit‿select_all() { | |
| 166 | getActiveTextEditor().selectAll(); | |
| 167 | } | |
| 168 | ||
| 169 | public void edit‿find() { | |
| 170 | final var nodes = getStatusBar().getLeftItems(); | |
| 171 | ||
| 172 | if( nodes.isEmpty() ) { | |
| 173 | final var searchBar = new SearchBar(); | |
| 174 | ||
| 175 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 176 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 177 | ||
| 178 | searchBar.setOnCancelAction( ( event ) -> { | |
| 179 | final var editor = getActiveTextEditor(); | |
| 180 | nodes.remove( searchBar ); | |
| 181 | editor.unstylize( STYLE_SEARCH ); | |
| 182 | editor.getNode().requestFocus(); | |
| 183 | } ); | |
| 184 | ||
| 185 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 186 | if( n != null && !n.isEmpty() ) { | |
| 187 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 188 | } | |
| 189 | } ); | |
| 190 | ||
| 191 | searchBar.setOnNextAction( ( event ) -> edit‿find_next() ); | |
| 192 | searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() ); | |
| 193 | ||
| 194 | nodes.add( searchBar ); | |
| 195 | searchBar.requestFocus(); | |
| 196 | } | |
| 197 | else { | |
| 198 | nodes.clear(); | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | public void edit‿find_next() { | |
| 203 | mSearchModel.advance(); | |
| 204 | } | |
| 205 | ||
| 206 | public void edit‿find_prev() { | |
| 207 | mSearchModel.retreat(); | |
| 208 | } | |
| 209 | ||
| 210 | public void edit‿preferences() { | |
| 211 | new PreferencesController( getWorkspace() ).show(); | |
| 212 | } | |
| 213 | ||
| 214 | public void format‿bold() { | |
| 215 | getActiveTextEditor().bold(); | |
| 216 | } | |
| 217 | ||
| 218 | public void format‿italic() { | |
| 219 | getActiveTextEditor().italic(); | |
| 220 | } | |
| 221 | ||
| 222 | public void format‿superscript() { | |
| 223 | getActiveTextEditor().superscript(); | |
| 224 | } | |
| 225 | ||
| 226 | public void format‿subscript() { | |
| 227 | getActiveTextEditor().subscript(); | |
| 228 | } | |
| 229 | ||
| 230 | public void format‿strikethrough() { | |
| 231 | getActiveTextEditor().strikethrough(); | |
| 232 | } | |
| 233 | ||
| 234 | public void insert‿blockquote() { | |
| 235 | getActiveTextEditor().blockquote(); | |
| 236 | } | |
| 237 | ||
| 238 | public void insert‿code() { | |
| 239 | getActiveTextEditor().code(); | |
| 240 | } | |
| 241 | ||
| 242 | public void insert‿fenced_code_block() { | |
| 243 | getActiveTextEditor().fencedCodeBlock(); | |
| 244 | } | |
| 245 | ||
| 246 | public void insert‿link() { | |
| 247 | createMarkdownDialog().insertLink( getActiveTextEditor().getTextArea() ); | |
| 248 | } | |
| 249 | ||
| 250 | public void insert‿image() { | |
| 251 | createMarkdownDialog().insertImage( getActiveTextEditor().getTextArea() ); | |
| 252 | } | |
| 253 | ||
| 254 | private MarkdownCommands createMarkdownDialog() { | |
| 255 | return new MarkdownCommands( | |
| 256 | getMainPane(), getActiveTextEditor().getPath() ); | |
| 257 | } | |
| 258 | ||
| 259 | public void insert‿heading_1() { | |
| 260 | insert‿heading( 1 ); | |
| 261 | } | |
| 262 | ||
| 263 | public void insert‿heading_2() { | |
| 264 | insert‿heading( 2 ); | |
| 265 | } | |
| 266 | ||
| 267 | public void insert‿heading_3() { | |
| 268 | insert‿heading( 3 ); | |
| 269 | } | |
| 270 | ||
| 271 | private void insert‿heading( final int level ) { | |
| 272 | getActiveTextEditor().heading( level ); | |
| 273 | } | |
| 274 | ||
| 275 | public void insert‿unordered_list() { | |
| 276 | getActiveTextEditor().unorderedList(); | |
| 277 | } | |
| 278 | ||
| 279 | public void insert‿ordered_list() { | |
| 280 | getActiveTextEditor().orderedList(); | |
| 281 | } | |
| 282 | ||
| 283 | public void insert‿horizontal_rule() { | |
| 284 | getActiveTextEditor().horizontalRule(); | |
| 285 | } | |
| 286 | ||
| 287 | public void definition‿create() { | |
| 288 | getActiveTextDefinition().createDefinition(); | |
| 289 | } | |
| 290 | ||
| 291 | public void definition‿rename() { | |
| 292 | getActiveTextDefinition().renameDefinition(); | |
| 293 | } | |
| 294 | ||
| 295 | public void definition‿delete() { | |
| 296 | getActiveTextDefinition().deleteDefinitions(); | |
| 297 | } | |
| 298 | ||
| 299 | public void definition‿autoinsert() { | |
| 300 | getMainPane().autoinsert(); | |
| 301 | } | |
| 302 | ||
| 303 | public void view‿refresh() { | |
| 304 | getMainPane().viewRefresh(); | |
| 305 | } | |
| 306 | ||
| 307 | public void view‿preview() { | |
| 308 | getMainPane().viewPreview(); | |
| 309 | } | |
| 310 | ||
| 311 | public void help‿about() { | |
| 312 | final Alert alert = new Alert( INFORMATION ); | |
| 313 | alert.setTitle( get( "Dialog.about.title", APP_TITLE ) ); | |
| 314 | alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) ); | |
| 315 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 316 | alert.setGraphic( new ImageView( ICON_DIALOG ) ); | |
| 317 | alert.initOwner( getWindow() ); | |
| 318 | alert.showAndWait(); | |
| 319 | } | |
| 320 | ||
| 321 | private FileChooserCommand createFileChooser() { | |
| 322 | final var dir = getWorkspace().fileProperty( KEY_UI_RECENT_DIR ); | |
| 323 | return new FileChooserCommand( getWindow(), dir ); | |
| 324 | } | |
| 325 | ||
| 326 | private MainPane getMainPane() { | |
| 327 | return mMainPane; | |
| 328 | } | |
| 329 | ||
| 330 | private TextEditor getActiveTextEditor() { | |
| 331 | return getMainPane().getActiveTextEditor(); | |
| 332 | } | |
| 333 | ||
| 334 | private TextDefinition getActiveTextDefinition() { | |
| 335 | return getMainPane().getActiveTextDefinition(); | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.actions; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.MainPane; | |
| 6 | import com.keenwrite.MainScene; | |
| 7 | import com.keenwrite.StatusNotifier; | |
| 8 | import com.keenwrite.editors.TextDefinition; | |
| 9 | import com.keenwrite.editors.TextEditor; | |
| 10 | import com.keenwrite.editors.markdown.HyperlinkModel; | |
| 11 | import com.keenwrite.editors.markdown.LinkVisitor; | |
| 12 | import com.keenwrite.preferences.PreferencesController; | |
| 13 | import com.keenwrite.preferences.Workspace; | |
| 14 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 15 | import com.keenwrite.search.SearchModel; | |
| 16 | import com.keenwrite.ui.controls.SearchBar; | |
| 17 | import com.keenwrite.ui.dialogs.ImageDialog; | |
| 18 | import com.keenwrite.ui.dialogs.LinkDialog; | |
| 19 | import com.vladsch.flexmark.ast.Link; | |
| 20 | import javafx.scene.control.Alert; | |
| 21 | import javafx.scene.control.Dialog; | |
| 22 | import javafx.stage.Window; | |
| 23 | import javafx.stage.WindowEvent; | |
| 24 | ||
| 25 | import static com.keenwrite.Bootstrap.APP_TITLE; | |
| 26 | import static com.keenwrite.Constants.ICON_DIALOG_NODE; | |
| 27 | import static com.keenwrite.ExportFormat.*; | |
| 28 | import static com.keenwrite.Messages.get; | |
| 29 | import static com.keenwrite.StatusNotifier.clue; | |
| 30 | import static com.keenwrite.StatusNotifier.getStatusBar; | |
| 31 | import static com.keenwrite.preferences.Workspace.KEY_UI_RECENT_DIR; | |
| 32 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 33 | import static java.nio.file.Files.writeString; | |
| 34 | import static javafx.event.Event.fireEvent; | |
| 35 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 36 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 37 | ||
| 38 | /** | |
| 39 | * Responsible for abstracting how functionality is mapped to the application. | |
| 40 | * This allows users to customize accelerator keys and will provide pluggable | |
| 41 | * functionality so that different text markup languages can change documents | |
| 42 | * using their respective syntax. | |
| 43 | */ | |
| 44 | @SuppressWarnings( "NonAsciiCharacters" ) | |
| 45 | public class ApplicationActions { | |
| 46 | private static final String STYLE_SEARCH = "search"; | |
| 47 | ||
| 48 | /** | |
| 49 | * When an action is executed, this is one of the recipients. | |
| 50 | */ | |
| 51 | private final MainPane mMainPane; | |
| 52 | ||
| 53 | private final MainScene mMainScene; | |
| 54 | ||
| 55 | /** | |
| 56 | * Tracks finding text in the active document. | |
| 57 | */ | |
| 58 | private final SearchModel mSearchModel; | |
| 59 | ||
| 60 | public ApplicationActions( final MainScene scene, final MainPane pane ) { | |
| 61 | mMainScene = scene; | |
| 62 | mMainPane = pane; | |
| 63 | mSearchModel = new SearchModel(); | |
| 64 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 65 | final var editor = getActiveTextEditor(); | |
| 66 | ||
| 67 | // Clear highlighted areas before adding highlighting to a new region. | |
| 68 | if( o != null ) { | |
| 69 | editor.unstylize( STYLE_SEARCH ); | |
| 70 | } | |
| 71 | ||
| 72 | if( n != null ) { | |
| 73 | editor.moveTo( n.getStart() ); | |
| 74 | editor.stylize( n, STYLE_SEARCH ); | |
| 75 | } | |
| 76 | } ); | |
| 77 | ||
| 78 | // When the active text editor changes, update the haystack. | |
| 79 | mMainPane.activeTextEditorProperty().addListener( | |
| 80 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 81 | ); | |
| 82 | } | |
| 83 | ||
| 84 | public void file‿new() { | |
| 85 | getMainPane().newTextEditor(); | |
| 86 | } | |
| 87 | ||
| 88 | public void file‿open() { | |
| 89 | getMainPane().open( createFileChooser().openFiles() ); | |
| 90 | } | |
| 91 | ||
| 92 | public void file‿close() { | |
| 93 | getMainPane().close(); | |
| 94 | } | |
| 95 | ||
| 96 | public void file‿close_all() { | |
| 97 | getMainPane().closeAll(); | |
| 98 | } | |
| 99 | ||
| 100 | public void file‿save() { | |
| 101 | getMainPane().save(); | |
| 102 | } | |
| 103 | ||
| 104 | public void file‿save_as() { | |
| 105 | final var file = createFileChooser().saveAs(); | |
| 106 | file.ifPresent( ( f ) -> getMainPane().saveAs( f ) ); | |
| 107 | } | |
| 108 | ||
| 109 | public void file‿save_all() { | |
| 110 | getMainPane().saveAll(); | |
| 111 | } | |
| 112 | ||
| 113 | public void file‿export‿html_svg() { | |
| 114 | file‿export( HTML_TEX_SVG ); | |
| 115 | } | |
| 116 | ||
| 117 | public void file‿export‿html_tex() { | |
| 118 | file‿export( HTML_TEX_DELIMITED ); | |
| 119 | } | |
| 120 | ||
| 121 | public void file‿export‿markdown() { | |
| 122 | file‿export( MARKDOWN_PLAIN ); | |
| 123 | } | |
| 124 | ||
| 125 | private void file‿export( final ExportFormat format ) { | |
| 126 | final var main = getMainPane(); | |
| 127 | final var context = main.createProcessorContext(); | |
| 128 | final var chain = createProcessors( context ); | |
| 129 | final var editor = main.getActiveTextEditor(); | |
| 130 | final var doc = editor.getText(); | |
| 131 | final var export = chain.apply( doc ); | |
| 132 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 133 | final var chooser = createFileChooser(); | |
| 134 | final var file = chooser.exportAs( filename ); | |
| 135 | ||
| 136 | file.ifPresent( ( f ) -> { | |
| 137 | try { | |
| 138 | writeString( f.toPath(), export ); | |
| 139 | final var m = get( "Main.status.export.success", f.toString() ); | |
| 140 | clue( m ); | |
| 141 | } catch( final Exception ex ) { | |
| 142 | clue( ex ); | |
| 143 | } | |
| 144 | } ); | |
| 145 | } | |
| 146 | ||
| 147 | public void file‿exit() { | |
| 148 | final var window = getWindow(); | |
| 149 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 150 | } | |
| 151 | ||
| 152 | public void edit‿undo() { | |
| 153 | getActiveTextEditor().undo(); | |
| 154 | } | |
| 155 | ||
| 156 | public void edit‿redo() { | |
| 157 | getActiveTextEditor().redo(); | |
| 158 | } | |
| 159 | ||
| 160 | public void edit‿cut() { | |
| 161 | getActiveTextEditor().cut(); | |
| 162 | } | |
| 163 | ||
| 164 | public void edit‿copy() { | |
| 165 | getActiveTextEditor().copy(); | |
| 166 | } | |
| 167 | ||
| 168 | public void edit‿paste() { | |
| 169 | getActiveTextEditor().paste(); | |
| 170 | } | |
| 171 | ||
| 172 | public void edit‿select_all() { | |
| 173 | getActiveTextEditor().selectAll(); | |
| 174 | } | |
| 175 | ||
| 176 | public void edit‿find() { | |
| 177 | final var nodes = getStatusBar().getLeftItems(); | |
| 178 | ||
| 179 | if( nodes.isEmpty() ) { | |
| 180 | final var searchBar = new SearchBar(); | |
| 181 | ||
| 182 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 183 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 184 | ||
| 185 | searchBar.setOnCancelAction( ( event ) -> { | |
| 186 | final var editor = getActiveTextEditor(); | |
| 187 | nodes.remove( searchBar ); | |
| 188 | editor.unstylize( STYLE_SEARCH ); | |
| 189 | editor.getNode().requestFocus(); | |
| 190 | } ); | |
| 191 | ||
| 192 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 193 | if( n != null && !n.isEmpty() ) { | |
| 194 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 195 | } | |
| 196 | } ); | |
| 197 | ||
| 198 | searchBar.setOnNextAction( ( event ) -> edit‿find_next() ); | |
| 199 | searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() ); | |
| 200 | ||
| 201 | nodes.add( searchBar ); | |
| 202 | searchBar.requestFocus(); | |
| 203 | } | |
| 204 | else { | |
| 205 | nodes.clear(); | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | public void edit‿find_next() { | |
| 210 | mSearchModel.advance(); | |
| 211 | } | |
| 212 | ||
| 213 | public void edit‿find_prev() { | |
| 214 | mSearchModel.retreat(); | |
| 215 | } | |
| 216 | ||
| 217 | public void edit‿preferences() { | |
| 218 | new PreferencesController( getWorkspace() ).show(); | |
| 219 | } | |
| 220 | ||
| 221 | public void format‿bold() { | |
| 222 | getActiveTextEditor().bold(); | |
| 223 | } | |
| 224 | ||
| 225 | public void format‿italic() { | |
| 226 | getActiveTextEditor().italic(); | |
| 227 | } | |
| 228 | ||
| 229 | public void format‿superscript() { | |
| 230 | getActiveTextEditor().superscript(); | |
| 231 | } | |
| 232 | ||
| 233 | public void format‿subscript() { | |
| 234 | getActiveTextEditor().subscript(); | |
| 235 | } | |
| 236 | ||
| 237 | public void format‿strikethrough() { | |
| 238 | getActiveTextEditor().strikethrough(); | |
| 239 | } | |
| 240 | ||
| 241 | public void insert‿blockquote() { | |
| 242 | getActiveTextEditor().blockquote(); | |
| 243 | } | |
| 244 | ||
| 245 | public void insert‿code() { | |
| 246 | getActiveTextEditor().code(); | |
| 247 | } | |
| 248 | ||
| 249 | public void insert‿fenced_code_block() { | |
| 250 | getActiveTextEditor().fencedCodeBlock(); | |
| 251 | } | |
| 252 | ||
| 253 | public void insert‿link() { | |
| 254 | insertObject( createLinkDialog() ); | |
| 255 | } | |
| 256 | ||
| 257 | public void insert‿image() { | |
| 258 | insertObject( createImageDialog() ); | |
| 259 | } | |
| 260 | ||
| 261 | private void insertObject( final Dialog<String> dialog ) { | |
| 262 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 263 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 264 | } | |
| 265 | ||
| 266 | private Dialog<String> createLinkDialog() { | |
| 267 | return new LinkDialog( getWindow(), createHyperlinkModel() ); | |
| 268 | } | |
| 269 | ||
| 270 | private Dialog<String> createImageDialog() { | |
| 271 | final var path = getActiveTextEditor().getPath(); | |
| 272 | final var parentDir = path.getParent(); | |
| 273 | return new ImageDialog( getWindow(), parentDir ); | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 278 | * the Markdown AST. | |
| 279 | * | |
| 280 | * @return An instance containing the link URL and display text. | |
| 281 | */ | |
| 282 | private HyperlinkModel createHyperlinkModel() { | |
| 283 | final var context = getMainPane().createProcessorContext(); | |
| 284 | final var editor = getActiveTextEditor(); | |
| 285 | final var textArea = editor.getTextArea(); | |
| 286 | final var selectedText = textArea.getSelectedText(); | |
| 287 | ||
| 288 | // Convert current paragraph to Markdown nodes. | |
| 289 | final var mp = MarkdownProcessor.create( context ); | |
| 290 | final var p = textArea.getCurrentParagraph(); | |
| 291 | final var paragraph = textArea.getText( p ); | |
| 292 | final var node = mp.toNode( paragraph ); | |
| 293 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 294 | final var link = visitor.process( node ); | |
| 295 | ||
| 296 | if( link != null ) { | |
| 297 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 298 | } | |
| 299 | ||
| 300 | return createHyperlinkModel( link, selectedText ); | |
| 301 | } | |
| 302 | ||
| 303 | private HyperlinkModel createHyperlinkModel( | |
| 304 | final Link link, final String selection ) { | |
| 305 | ||
| 306 | return link == null | |
| 307 | ? new HyperlinkModel( selection, "https://localhost" ) | |
| 308 | : new HyperlinkModel( link ); | |
| 309 | } | |
| 310 | ||
| 311 | public void insert‿heading_1() { | |
| 312 | insert‿heading( 1 ); | |
| 313 | } | |
| 314 | ||
| 315 | public void insert‿heading_2() { | |
| 316 | insert‿heading( 2 ); | |
| 317 | } | |
| 318 | ||
| 319 | public void insert‿heading_3() { | |
| 320 | insert‿heading( 3 ); | |
| 321 | } | |
| 322 | ||
| 323 | private void insert‿heading( final int level ) { | |
| 324 | getActiveTextEditor().heading( level ); | |
| 325 | } | |
| 326 | ||
| 327 | public void insert‿unordered_list() { | |
| 328 | getActiveTextEditor().unorderedList(); | |
| 329 | } | |
| 330 | ||
| 331 | public void insert‿ordered_list() { | |
| 332 | getActiveTextEditor().orderedList(); | |
| 333 | } | |
| 334 | ||
| 335 | public void insert‿horizontal_rule() { | |
| 336 | getActiveTextEditor().horizontalRule(); | |
| 337 | } | |
| 338 | ||
| 339 | public void definition‿create() { | |
| 340 | getActiveTextDefinition().createDefinition(); | |
| 341 | } | |
| 342 | ||
| 343 | public void definition‿rename() { | |
| 344 | getActiveTextDefinition().renameDefinition(); | |
| 345 | } | |
| 346 | ||
| 347 | public void definition‿delete() { | |
| 348 | getActiveTextDefinition().deleteDefinitions(); | |
| 349 | } | |
| 350 | ||
| 351 | public void definition‿autoinsert() { | |
| 352 | getMainPane().autoinsert(); | |
| 353 | } | |
| 354 | ||
| 355 | public void view‿refresh() { | |
| 356 | getMainPane().viewRefresh(); | |
| 357 | } | |
| 358 | ||
| 359 | public void view‿preview() { | |
| 360 | getMainPane().viewPreview(); | |
| 361 | } | |
| 362 | ||
| 363 | public void view‿menubar() { | |
| 364 | getMainScene().toggleMenuBar(); | |
| 365 | } | |
| 366 | ||
| 367 | public void view‿toolbar() { | |
| 368 | getMainScene().toggleToolBar(); | |
| 369 | } | |
| 370 | ||
| 371 | public void view‿statusbar() { | |
| 372 | getMainScene().toggleStatusBar(); | |
| 373 | } | |
| 374 | ||
| 375 | public void view‿issues() { | |
| 376 | StatusNotifier.viewIssues(); | |
| 377 | } | |
| 378 | ||
| 379 | public void help‿about() { | |
| 380 | final Alert alert = new Alert( INFORMATION ); | |
| 381 | alert.setTitle( get( "Dialog.about.title", APP_TITLE ) ); | |
| 382 | alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) ); | |
| 383 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 384 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 385 | alert.initOwner( getWindow() ); | |
| 386 | alert.showAndWait(); | |
| 387 | } | |
| 388 | ||
| 389 | private FileChooserCommand createFileChooser() { | |
| 390 | final var dir = getWorkspace().fileProperty( KEY_UI_RECENT_DIR ); | |
| 391 | return new FileChooserCommand( getWindow(), dir ); | |
| 392 | } | |
| 393 | ||
| 394 | private TextEditor getActiveTextEditor() { | |
| 395 | return getMainPane().getActiveTextEditor(); | |
| 396 | } | |
| 397 | ||
| 398 | private TextDefinition getActiveTextDefinition() { | |
| 399 | return getMainPane().getActiveTextDefinition(); | |
| 400 | } | |
| 401 | ||
| 402 | private MainScene getMainScene() { | |
| 403 | return mMainScene; | |
| 404 | } | |
| 405 | ||
| 406 | private MainPane getMainPane() { | |
| 407 | return mMainPane; | |
| 336 | 408 | } |
| 337 | 409 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.actions; | |
| 3 | ||
| 4 | import javafx.event.ActionEvent; | |
| 5 | import javafx.event.EventHandler; | |
| 6 | import javafx.scene.Node; | |
| 7 | import javafx.scene.control.Menu; | |
| 8 | import javafx.scene.control.MenuBar; | |
| 9 | import javafx.scene.control.MenuItem; | |
| 10 | import javafx.scene.control.ToolBar; | |
| 11 | ||
| 12 | import java.util.HashMap; | |
| 13 | import java.util.Map; | |
| 14 | ||
| 15 | import static com.keenwrite.Messages.get; | |
| 16 | ||
| 17 | /** | |
| 18 | * Responsible for wiring all application actions to menus, toolbar buttons, | |
| 19 | * and keyboard shortcuts. | |
| 20 | */ | |
| 21 | public class ApplicationBars { | |
| 22 | ||
| 23 | private static final Map<String, Action> sMap = new HashMap<>( 64 ); | |
| 24 | ||
| 25 | /** | |
| 26 | * Empty constructor. | |
| 27 | */ | |
| 28 | public ApplicationBars() { | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * Creates the main application affordances. | |
| 33 | * | |
| 34 | * @param actions The {@link ApplicationActions} that map user interface | |
| 35 | * selections to executable code. | |
| 36 | * @return An instance of {@link Node} that contains the menu and toolbar. | |
| 37 | */ | |
| 38 | public static Node createMenuBar( final ApplicationActions actions ) { | |
| 39 | final var SEPARATOR_ACTION = new SeparatorAction(); | |
| 40 | ||
| 41 | //@formatter:off | |
| 42 | return new MenuBar( | |
| 43 | createMenu( | |
| 44 | get( "Main.menu.file" ), | |
| 45 | addAction( "file.new", e -> actions.file‿new() ), | |
| 46 | addAction( "file.open", e -> actions.file‿open() ), | |
| 47 | SEPARATOR_ACTION, | |
| 48 | addAction( "file.close", e -> actions.file‿close() ), | |
| 49 | addAction( "file.close_all", e -> actions.file‿close_all() ), | |
| 50 | SEPARATOR_ACTION, | |
| 51 | addAction( "file.save", e -> actions.file‿save() ), | |
| 52 | addAction( "file.save_as", e -> actions.file‿save_as() ), | |
| 53 | addAction( "file.save_all", e -> actions.file‿save_all() ), | |
| 54 | SEPARATOR_ACTION, | |
| 55 | addAction( "file.export", e -> {} ) | |
| 56 | .addSubActions( | |
| 57 | addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ), | |
| 58 | addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ), | |
| 59 | addAction( "file.export.markdown", e -> actions.file‿export‿markdown() ) | |
| 60 | ), | |
| 61 | SEPARATOR_ACTION, | |
| 62 | addAction( "file.exit", e -> actions.file‿exit() ) | |
| 63 | ), | |
| 64 | createMenu( | |
| 65 | get( "Main.menu.edit" ), | |
| 66 | SEPARATOR_ACTION, | |
| 67 | addAction( "edit.undo", e -> actions.edit‿undo() ), | |
| 68 | addAction( "edit.redo", e -> actions.edit‿redo() ), | |
| 69 | SEPARATOR_ACTION, | |
| 70 | addAction( "edit.cut", e -> actions.edit‿cut() ), | |
| 71 | addAction( "edit.copy", e -> actions.edit‿copy() ), | |
| 72 | addAction( "edit.paste", e -> actions.edit‿paste() ), | |
| 73 | addAction( "edit.select_all", e -> actions.edit‿select_all() ), | |
| 74 | SEPARATOR_ACTION, | |
| 75 | addAction( "edit.find", e -> actions.edit‿find() ), | |
| 76 | addAction( "edit.find_next", e -> actions.edit‿find_next() ), | |
| 77 | addAction( "edit.find_prev", e -> actions.edit‿find_prev() ), | |
| 78 | SEPARATOR_ACTION, | |
| 79 | addAction( "edit.preferences", e -> actions.edit‿preferences() ) | |
| 80 | ), | |
| 81 | createMenu( | |
| 82 | get( "Main.menu.format" ), | |
| 83 | addAction( "format.bold", e -> actions.format‿bold() ), | |
| 84 | addAction( "format.italic", e -> actions.format‿italic() ), | |
| 85 | addAction( "format.superscript", e -> actions.format‿superscript() ), | |
| 86 | addAction( "format.subscript", e -> actions.format‿subscript() ), | |
| 87 | addAction( "format.strikethrough", e -> actions.format‿strikethrough() ) | |
| 88 | ), | |
| 89 | createMenu( | |
| 90 | get( "Main.menu.insert" ), | |
| 91 | addAction( "insert.blockquote", e -> actions.insert‿blockquote() ), | |
| 92 | addAction( "insert.code", e -> actions.insert‿code() ), | |
| 93 | addAction( "insert.fenced_code_block", e -> actions.insert‿fenced_code_block() ), | |
| 94 | SEPARATOR_ACTION, | |
| 95 | addAction( "insert.link", e -> actions.insert‿link() ), | |
| 96 | addAction( "insert.image", e -> actions.insert‿image() ), | |
| 97 | SEPARATOR_ACTION, | |
| 98 | addAction( "insert.heading_1", e -> actions.insert‿heading_1() ), | |
| 99 | addAction( "insert.heading_2", e -> actions.insert‿heading_2() ), | |
| 100 | addAction( "insert.heading_3", e -> actions.insert‿heading_3() ), | |
| 101 | SEPARATOR_ACTION, | |
| 102 | addAction( "insert.unordered_list", e -> actions.insert‿unordered_list() ), | |
| 103 | addAction( "insert.ordered_list", e -> actions.insert‿ordered_list() ), | |
| 104 | addAction( "insert.horizontal_rule", e -> actions.insert‿horizontal_rule() ) | |
| 105 | ), | |
| 106 | createMenu( | |
| 107 | get( "Main.menu.definition" ), | |
| 108 | addAction( "definition.insert", e -> actions.definition‿autoinsert() ), | |
| 109 | SEPARATOR_ACTION, | |
| 110 | addAction( "definition.create", e -> actions.definition‿create() ), | |
| 111 | addAction( "definition.rename", e -> actions.definition‿rename() ), | |
| 112 | addAction( "definition.delete", e -> actions.definition‿delete() ) | |
| 113 | ), | |
| 114 | createMenu( | |
| 115 | get( "Main.menu.view" ), | |
| 116 | addAction( "view.refresh", e -> actions.view‿refresh() ), | |
| 117 | SEPARATOR_ACTION, | |
| 118 | addAction( "view.issues", e -> actions.view‿issues() ), | |
| 119 | addAction( "view.preview", e -> actions.view‿preview() ), | |
| 120 | SEPARATOR_ACTION, | |
| 121 | addAction( "view.toolbar", e -> actions.view‿toolbar() ), | |
| 122 | addAction( "view.statusbar", e -> actions.view‿statusbar() ), | |
| 123 | addAction( "view.menubar", e -> actions.view‿menubar() ) | |
| 124 | ), | |
| 125 | createMenu( | |
| 126 | get( "Main.menu.help" ), | |
| 127 | addAction( "help.about", e -> actions.help‿about() ) | |
| 128 | ) ); | |
| 129 | //@formatter:on | |
| 130 | } | |
| 131 | ||
| 132 | public static Node createToolBar() { | |
| 133 | final var SEPARATOR_ACTION = new SeparatorAction(); | |
| 134 | ||
| 135 | return createToolBar( | |
| 136 | getAction( "file.new" ), | |
| 137 | getAction( "file.open" ), | |
| 138 | getAction( "file.save" ), | |
| 139 | SEPARATOR_ACTION, | |
| 140 | getAction( "edit.undo" ), | |
| 141 | getAction( "edit.redo" ), | |
| 142 | getAction( "edit.cut" ), | |
| 143 | getAction( "edit.copy" ), | |
| 144 | getAction( "edit.paste" ), | |
| 145 | SEPARATOR_ACTION, | |
| 146 | getAction( "format.bold" ), | |
| 147 | getAction( "format.italic" ), | |
| 148 | getAction( "format.superscript" ), | |
| 149 | getAction( "format.subscript" ), | |
| 150 | getAction( "insert.blockquote" ), | |
| 151 | getAction( "insert.code" ), | |
| 152 | getAction( "insert.fenced_code_block" ), | |
| 153 | SEPARATOR_ACTION, | |
| 154 | getAction( "insert.link" ), | |
| 155 | getAction( "insert.image" ), | |
| 156 | SEPARATOR_ACTION, | |
| 157 | getAction( "insert.heading_1" ), | |
| 158 | SEPARATOR_ACTION, | |
| 159 | getAction( "insert.unordered_list" ), | |
| 160 | getAction( "insert.ordered_list" ) | |
| 161 | ); | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Adds a new action to the list of actions. | |
| 166 | * | |
| 167 | * @param key The name of the action to register in {@link #sMap}. | |
| 168 | * @param handler Performs the action upon request. | |
| 169 | * @return The newly registered action. | |
| 170 | */ | |
| 171 | private static Action addAction( | |
| 172 | final String key, final EventHandler<ActionEvent> handler ) { | |
| 173 | assert key != null; | |
| 174 | assert handler != null; | |
| 175 | ||
| 176 | final var action = Action | |
| 177 | .builder() | |
| 178 | .setId( key ) | |
| 179 | .setHandler( handler ) | |
| 180 | .build(); | |
| 181 | ||
| 182 | sMap.put( key, action ); | |
| 183 | ||
| 184 | return action; | |
| 185 | } | |
| 186 | ||
| 187 | private static Action getAction( final String key ) { | |
| 188 | return sMap.get( key ); | |
| 189 | } | |
| 190 | ||
| 191 | public static Menu createMenu( | |
| 192 | final String text, final MenuAction... actions ) { | |
| 193 | return new Menu( text, null, createMenuItems( actions ) ); | |
| 194 | } | |
| 195 | ||
| 196 | public static MenuItem[] createMenuItems( final MenuAction... actions ) { | |
| 197 | final var menuItems = new MenuItem[ actions.length ]; | |
| 198 | ||
| 199 | for( var i = 0; i < actions.length; i++ ) { | |
| 200 | menuItems[ i ] = actions[ i ].createMenuItem(); | |
| 201 | } | |
| 202 | ||
| 203 | return menuItems; | |
| 204 | } | |
| 205 | ||
| 206 | private static ToolBar createToolBar( final MenuAction... actions ) { | |
| 207 | return new ToolBar( createToolBarButtons( actions ) ); | |
| 208 | } | |
| 209 | ||
| 210 | private static Node[] createToolBarButtons( final MenuAction... actions ) { | |
| 211 | final var len = actions.length; | |
| 212 | final var nodes = new Node[ len ]; | |
| 213 | ||
| 214 | for( var i = 0; i < len; i++ ) { | |
| 215 | nodes[ i ] = actions[ i ].createToolBarNode(); | |
| 216 | } | |
| 217 | ||
| 218 | return nodes; | |
| 219 | } | |
| 220 | } | |
| 1 | 221 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.actions; | |
| 3 | ||
| 4 | import javafx.event.ActionEvent; | |
| 5 | import javafx.event.EventHandler; | |
| 6 | import javafx.scene.Node; | |
| 7 | import javafx.scene.control.Menu; | |
| 8 | import javafx.scene.control.MenuBar; | |
| 9 | import javafx.scene.control.MenuItem; | |
| 10 | import javafx.scene.control.ToolBar; | |
| 11 | import javafx.scene.layout.VBox; | |
| 12 | ||
| 13 | import java.util.HashMap; | |
| 14 | import java.util.Map; | |
| 15 | ||
| 16 | import static com.keenwrite.Messages.get; | |
| 17 | ||
| 18 | /** | |
| 19 | * Responsible for wiring all application actions to menus, toolbar buttons, | |
| 20 | * and keyboard shortcuts. | |
| 21 | */ | |
| 22 | public class ApplicationMenuBar { | |
| 23 | ||
| 24 | private final Map<String, Action> mMap = new HashMap<>( 64 ); | |
| 25 | ||
| 26 | /** | |
| 27 | * Empty constructor. | |
| 28 | */ | |
| 29 | public ApplicationMenuBar() { | |
| 30 | } | |
| 31 | ||
| 32 | /** | |
| 33 | * Creates the main application affordances. | |
| 34 | * | |
| 35 | * @param actions The {@link ApplicationActions} that map user interface | |
| 36 | * selections to executable code. | |
| 37 | * @return An instance of {@link Node} that contains the menu and toolbar. | |
| 38 | */ | |
| 39 | public Node createMenuBar( final ApplicationActions actions ) { | |
| 40 | final var SEPARATOR_ACTION = new SeparatorAction(); | |
| 41 | ||
| 42 | //@formatter:off | |
| 43 | final var menuBar = new MenuBar( | |
| 44 | createMenu( | |
| 45 | get( "Main.menu.file" ), | |
| 46 | addAction( "file.new", e -> actions.file‿new() ), | |
| 47 | addAction( "file.open", e -> actions.file‿open() ), | |
| 48 | SEPARATOR_ACTION, | |
| 49 | addAction( "file.close", e -> actions.file‿close() ), | |
| 50 | addAction( "file.close_all", e -> actions.file‿close_all() ), | |
| 51 | SEPARATOR_ACTION, | |
| 52 | addAction( "file.save", e -> actions.file‿save() ), | |
| 53 | addAction( "file.save_as", e -> actions.file‿save_as() ), | |
| 54 | addAction( "file.save_all", e -> actions.file‿save_all() ), | |
| 55 | SEPARATOR_ACTION, | |
| 56 | addAction( "file.export", e -> {} ) | |
| 57 | .addSubActions( | |
| 58 | addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ), | |
| 59 | addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ), | |
| 60 | addAction( "file.export.markdown", e -> actions.file‿export‿markdown() ) | |
| 61 | ), | |
| 62 | SEPARATOR_ACTION, | |
| 63 | addAction( "file.exit", e -> actions.file‿exit() ) | |
| 64 | ), | |
| 65 | createMenu( | |
| 66 | get( "Main.menu.edit" ), | |
| 67 | SEPARATOR_ACTION, | |
| 68 | addAction( "edit.undo", e -> actions.edit‿undo() ), | |
| 69 | addAction( "edit.redo", e -> actions.edit‿redo() ), | |
| 70 | SEPARATOR_ACTION, | |
| 71 | addAction( "edit.cut", e -> actions.edit‿cut() ), | |
| 72 | addAction( "edit.copy", e -> actions.edit‿copy() ), | |
| 73 | addAction( "edit.paste", e -> actions.edit‿paste() ), | |
| 74 | addAction( "edit.select_all", e -> actions.edit‿select_all() ), | |
| 75 | SEPARATOR_ACTION, | |
| 76 | addAction( "edit.find", e -> actions.edit‿find() ), | |
| 77 | addAction( "edit.find_next", e -> actions.edit‿find_next() ), | |
| 78 | addAction( "edit.find_prev", e -> actions.edit‿find_prev() ), | |
| 79 | SEPARATOR_ACTION, | |
| 80 | addAction( "edit.preferences", e -> actions.edit‿preferences() ) | |
| 81 | ), | |
| 82 | createMenu( | |
| 83 | get( "Main.menu.format" ), | |
| 84 | addAction( "format.bold", e -> actions.format‿bold() ), | |
| 85 | addAction( "format.italic", e -> actions.format‿italic() ), | |
| 86 | addAction( "format.superscript", e -> actions.format‿superscript() ), | |
| 87 | addAction( "format.subscript", e -> actions.format‿subscript() ), | |
| 88 | addAction( "format.strikethrough", e -> actions.format‿strikethrough() ) | |
| 89 | ), | |
| 90 | createMenu( | |
| 91 | get( "Main.menu.insert" ), | |
| 92 | addAction( "insert.blockquote", e -> actions.insert‿blockquote() ), | |
| 93 | addAction( "insert.code", e -> actions.insert‿code() ), | |
| 94 | addAction( "insert.fenced_code_block", e -> actions.insert‿fenced_code_block() ), | |
| 95 | SEPARATOR_ACTION, | |
| 96 | addAction( "insert.link", e -> actions.insert‿link() ), | |
| 97 | addAction( "insert.image", e -> actions.insert‿image() ), | |
| 98 | SEPARATOR_ACTION, | |
| 99 | addAction( "insert.heading_1", e -> actions.insert‿heading_1() ), | |
| 100 | addAction( "insert.heading_2", e -> actions.insert‿heading_2() ), | |
| 101 | addAction( "insert.heading_3", e -> actions.insert‿heading_3() ), | |
| 102 | SEPARATOR_ACTION, | |
| 103 | addAction( "insert.unordered_list", e -> actions.insert‿unordered_list() ), | |
| 104 | addAction( "insert.ordered_list", e -> actions.insert‿ordered_list() ), | |
| 105 | addAction( "insert.horizontal_rule", e -> actions.insert‿horizontal_rule() ) | |
| 106 | ), | |
| 107 | createMenu( | |
| 108 | get( "Main.menu.definition" ), | |
| 109 | addAction( "definition.insert", e -> actions.definition‿autoinsert() ), | |
| 110 | SEPARATOR_ACTION, | |
| 111 | addAction( "definition.create", e -> actions.definition‿create() ), | |
| 112 | addAction( "definition.rename", e -> actions.definition‿rename() ), | |
| 113 | addAction( "definition.delete", e -> actions.definition‿delete() ) | |
| 114 | ), | |
| 115 | createMenu( | |
| 116 | get( "Main.menu.view" ), | |
| 117 | addAction( "view.refresh", e -> actions.view‿refresh() ), | |
| 118 | SEPARATOR_ACTION, | |
| 119 | addAction( "view.preview", e -> actions.view‿preview() ) | |
| 120 | ), | |
| 121 | createMenu( | |
| 122 | get( "Main.menu.help" ), | |
| 123 | addAction( "help.about", e -> actions.help‿about() ) | |
| 124 | ) ); | |
| 125 | //@formatter:on | |
| 126 | ||
| 127 | //@formatter:off | |
| 128 | final var toolBar = createToolBar( | |
| 129 | getAction( "file.new" ), | |
| 130 | getAction( "file.open" ), | |
| 131 | getAction( "file.save" ), | |
| 132 | SEPARATOR_ACTION, | |
| 133 | getAction( "edit.undo" ), | |
| 134 | getAction( "edit.redo" ), | |
| 135 | getAction( "edit.cut" ), | |
| 136 | getAction( "edit.copy" ), | |
| 137 | getAction( "edit.paste" ), | |
| 138 | SEPARATOR_ACTION, | |
| 139 | getAction( "format.bold" ), | |
| 140 | getAction( "format.italic" ), | |
| 141 | getAction( "format.superscript" ), | |
| 142 | getAction( "format.subscript" ), | |
| 143 | getAction( "insert.blockquote" ), | |
| 144 | getAction( "insert.code" ), | |
| 145 | getAction( "insert.fenced_code_block" ), | |
| 146 | SEPARATOR_ACTION, | |
| 147 | getAction( "insert.link" ), | |
| 148 | getAction( "insert.image" ), | |
| 149 | SEPARATOR_ACTION, | |
| 150 | getAction( "insert.heading_1" ), | |
| 151 | SEPARATOR_ACTION, | |
| 152 | getAction( "insert.unordered_list" ), | |
| 153 | getAction( "insert.ordered_list" ) | |
| 154 | ); | |
| 155 | //@formatter:on | |
| 156 | ||
| 157 | return new VBox( menuBar, toolBar ); | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * Adds a new action to the list of actions. | |
| 162 | * | |
| 163 | * @param key The name of the action to register in {@link #mMap}. | |
| 164 | * @param handler Performs the action upon request. | |
| 165 | * @return The newly registered action. | |
| 166 | */ | |
| 167 | private Action addAction( | |
| 168 | final String key, final EventHandler<ActionEvent> handler ) { | |
| 169 | assert key != null; | |
| 170 | assert handler != null; | |
| 171 | ||
| 172 | final var action = Action | |
| 173 | .builder() | |
| 174 | .setId( key ) | |
| 175 | .setHandler( handler ) | |
| 176 | .build(); | |
| 177 | ||
| 178 | mMap.put( key, action ); | |
| 179 | ||
| 180 | return action; | |
| 181 | } | |
| 182 | ||
| 183 | private Action getAction( final String key ) { | |
| 184 | return mMap.get( key ); | |
| 185 | } | |
| 186 | ||
| 187 | public static Menu createMenu( | |
| 188 | final String text, final MenuAction... actions ) { | |
| 189 | return new Menu( text, null, createMenuItems( actions ) ); | |
| 190 | } | |
| 191 | ||
| 192 | public static MenuItem[] createMenuItems( final MenuAction... actions ) { | |
| 193 | final var menuItems = new MenuItem[ actions.length ]; | |
| 194 | ||
| 195 | for( var i = 0; i < actions.length; i++ ) { | |
| 196 | menuItems[ i ] = actions[ i ].createMenuItem(); | |
| 197 | } | |
| 198 | ||
| 199 | return menuItems; | |
| 200 | } | |
| 201 | ||
| 202 | private static ToolBar createToolBar( final MenuAction... actions ) { | |
| 203 | return new ToolBar( createToolBarButtons( actions ) ); | |
| 204 | } | |
| 205 | ||
| 206 | private static Node[] createToolBarButtons( final MenuAction... actions ) { | |
| 207 | final var len = actions.length; | |
| 208 | final var nodes = new Node[ len ]; | |
| 209 | ||
| 210 | for( var i = 0; i < len; i++ ) { | |
| 211 | nodes[ i ] = actions[ i ].createToolBarNode(); | |
| 212 | } | |
| 213 | ||
| 214 | return nodes; | |
| 215 | } | |
| 216 | } | |
| 217 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.ui.actions; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.actions; | |
| 3 | ||
| 4 | import com.keenwrite.MainPane; | |
| 5 | import com.keenwrite.editors.markdown.HyperlinkModel; | |
| 6 | import com.keenwrite.editors.markdown.LinkVisitor; | |
| 7 | import com.keenwrite.preferences.Workspace; | |
| 8 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 9 | import com.keenwrite.ui.dialogs.ImageDialog; | |
| 10 | import com.keenwrite.ui.dialogs.LinkDialog; | |
| 11 | import com.vladsch.flexmark.ast.Link; | |
| 12 | import javafx.scene.control.Dialog; | |
| 13 | import javafx.stage.Window; | |
| 14 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 15 | ||
| 16 | import java.nio.file.Path; | |
| 17 | ||
| 18 | /** | |
| 19 | * TODO: Integrate the methods into {@link ApplicationActions} | |
| 20 | * | |
| 21 | * @deprecated Migrate into {@link ApplicationActions}. | |
| 22 | */ | |
| 23 | @Deprecated | |
| 24 | public class MarkdownCommands { | |
| 25 | ||
| 26 | private final MainPane mParent; | |
| 27 | private final Path mBase; | |
| 28 | ||
| 29 | public MarkdownCommands( final MainPane parent, final Path path ) { | |
| 30 | mParent = parent; | |
| 31 | mBase = path.getParent(); | |
| 32 | } | |
| 33 | ||
| 34 | public void insertLink( final StyleClassedTextArea textArea ) { | |
| 35 | insertObject( createLinkDialog( textArea ), textArea ); | |
| 36 | } | |
| 37 | ||
| 38 | public void insertImage( final StyleClassedTextArea textArea ) { | |
| 39 | insertObject( createImageDialog(), textArea ); | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 44 | * the markdown AST. | |
| 45 | * | |
| 46 | * @return An instance containing the link URL and display text. | |
| 47 | */ | |
| 48 | private HyperlinkModel getHyperlink( final StyleClassedTextArea textArea ) { | |
| 49 | final var selectedText = textArea.getSelectedText(); | |
| 50 | ||
| 51 | // Get the current paragraph, convert to Markdown nodes. | |
| 52 | final var mp = MarkdownProcessor.create( getWorkspace() ); | |
| 53 | final var p = textArea.getCurrentParagraph(); | |
| 54 | final var paragraph = textArea.getText( p ); | |
| 55 | final var node = mp.toNode( paragraph ); | |
| 56 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 57 | final var link = visitor.process( node ); | |
| 58 | ||
| 59 | if( link != null ) { | |
| 60 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 61 | } | |
| 62 | ||
| 63 | return createHyperlinkModel( | |
| 64 | link, selectedText, "https://localhost" | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | @SuppressWarnings("SameParameterValue") | |
| 69 | private HyperlinkModel createHyperlinkModel( | |
| 70 | final Link link, final String selection, final String url ) { | |
| 71 | ||
| 72 | return link == null | |
| 73 | ? new HyperlinkModel( selection, url ) | |
| 74 | : new HyperlinkModel( link ); | |
| 75 | } | |
| 76 | ||
| 77 | private Dialog<String> createLinkDialog( | |
| 78 | final StyleClassedTextArea textArea ) { | |
| 79 | return new LinkDialog( getWindow(), getHyperlink( textArea ) ); | |
| 80 | } | |
| 81 | ||
| 82 | private Dialog<String> createImageDialog() { | |
| 83 | return new ImageDialog( getWindow(), getParentPath() ); | |
| 84 | } | |
| 85 | ||
| 86 | private void insertObject( | |
| 87 | final Dialog<String> dialog, final StyleClassedTextArea textArea ) { | |
| 88 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 89 | } | |
| 90 | ||
| 91 | private Path getParentPath() { | |
| 92 | return mBase; | |
| 93 | } | |
| 94 | ||
| 95 | private Workspace getWorkspace() { | |
| 96 | return mParent.getWorkspace(); | |
| 97 | } | |
| 98 | ||
| 99 | private Window getWindow() { | |
| 100 | return mParent.getWindow(); | |
| 101 | } | |
| 102 | } | |
| 103 | 1 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.ui.actions; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.ui.actions; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. | |
| 2 | 2 | * |
| 3 | 3 | * All rights reserved. |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.ui.adapters; |
| 3 | 3 | |
| 4 | 4 | import org.xhtmlrenderer.event.DocumentListener; |
| 5 | 5 | |
| 6 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 6 | import static com.keenwrite.StatusNotifier.clue; | |
| 7 | 7 | |
| 8 | 8 | /** |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.ui.adapters; |
| 3 | 3 |
| 34 | 34 | |
| 35 | 35 | /** |
| 36 | * Responsible for escaping/unescaping characters for markdown. | |
| 36 | * Responsible for escaping/unescaping characters for Markdown. | |
| 37 | 37 | */ |
| 38 | 38 | public class EscapeTextField extends TextField { |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.ui.controls; |
| 3 | 3 |
| 1 | /* | |
| 2 | * Copyright 2017 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 1 | /* Copyright 2017-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 28 | 2 | package com.keenwrite.ui.dialogs; |
| 29 | 3 | |
| 30 | import static com.keenwrite.Messages.get; | |
| 31 | 4 | import com.keenwrite.service.events.impl.ButtonOrderPane; |
| 32 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 33 | import static javafx.scene.control.ButtonType.OK; | |
| 34 | 5 | import javafx.scene.control.Dialog; |
| 6 | import javafx.stage.Stage; | |
| 35 | 7 | import javafx.stage.Window; |
| 8 | ||
| 9 | import static com.keenwrite.Constants.ICON_DIALOG; | |
| 10 | import static com.keenwrite.Messages.get; | |
| 11 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 12 | import static javafx.scene.control.ButtonType.OK; | |
| 36 | 13 | |
| 37 | 14 | /** |
| ... | ||
| 58 | 35 | initDialogButtons(); |
| 59 | 36 | initComponents(); |
| 37 | initIcon( (Stage) owner ); | |
| 60 | 38 | } |
| 61 | 39 | |
| ... | ||
| 71 | 49 | setDialogPane( new ButtonOrderPane() ); |
| 72 | 50 | } |
| 73 | ||
| 51 | ||
| 74 | 52 | /** |
| 75 | 53 | * Set an OK and CANCEL button on the dialog. |
| 76 | 54 | */ |
| 77 | 55 | protected void initDialogButtons() { |
| 78 | 56 | getDialogPane().getButtonTypes().addAll( OK, CANCEL ); |
| 79 | 57 | } |
| 80 | 58 | |
| 81 | 59 | /** |
| 82 | * Attaches a setOnCloseRequest to the dialog's [X] button so that the user | |
| 60 | * Attaches a close request to the dialog's [X] button so that the user | |
| 83 | 61 | * can always close the window, even if there's an error. |
| 84 | 62 | */ |
| 85 | 63 | protected final void initCloseAction() { |
| 86 | 64 | final Window window = getDialogPane().getScene().getWindow(); |
| 87 | 65 | window.setOnCloseRequest( event -> window.hide() ); |
| 66 | } | |
| 67 | ||
| 68 | private void initIcon( final Stage owner ) { | |
| 69 | owner.getIcons().add( ICON_DIALOG ); | |
| 88 | 70 | } |
| 89 | 71 | } |
| 44 | 44 | |
| 45 | 45 | /** |
| 46 | * Dialog to enter a markdown image. | |
| 46 | * Dialog to enter a Markdown image. | |
| 47 | 47 | */ |
| 48 | 48 | public class ImageDialog extends AbstractDialog<String> { |
| 44 | 44 | |
| 45 | 45 | /** |
| 46 | * Dialog to enter a markdown link. | |
| 46 | * Dialog to enter a Markdown link. | |
| 47 | 47 | */ |
| 48 | 48 | public class LinkDialog extends AbstractDialog<String> { |
| 2 | 2 | |
| 3 | 3 | import com.keenwrite.editors.TextEditor; |
| 4 | import com.keenwrite.processors.markdown.Caret; | |
| 4 | import com.keenwrite.Caret; | |
| 5 | 5 | import javafx.beans.property.ReadOnlyObjectProperty; |
| 6 | 6 | import javafx.beans.value.ChangeListener; |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.logging; | |
| 3 | ||
| 4 | import com.keenwrite.MainApp; | |
| 5 | import javafx.beans.property.SimpleStringProperty; | |
| 6 | import javafx.beans.property.StringProperty; | |
| 7 | import javafx.collections.ObservableList; | |
| 8 | import javafx.scene.control.*; | |
| 9 | import javafx.scene.input.ClipboardContent; | |
| 10 | import javafx.scene.input.KeyCodeCombination; | |
| 11 | import javafx.stage.Stage; | |
| 12 | ||
| 13 | import java.time.LocalDateTime; | |
| 14 | import java.util.TreeSet; | |
| 15 | import java.util.stream.Collectors; | |
| 16 | ||
| 17 | import static com.keenwrite.Constants.ICON_DIALOG; | |
| 18 | import static com.keenwrite.Constants.NEWLINE; | |
| 19 | import static com.keenwrite.Messages.get; | |
| 20 | import static java.time.LocalDateTime.now; | |
| 21 | import static java.time.format.DateTimeFormatter.ofPattern; | |
| 22 | import static java.util.Arrays.stream; | |
| 23 | import static javafx.collections.FXCollections.observableArrayList; | |
| 24 | import static javafx.event.ActionEvent.ACTION; | |
| 25 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 26 | import static javafx.scene.control.ButtonType.OK; | |
| 27 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 28 | import static javafx.scene.input.Clipboard.getSystemClipboard; | |
| 29 | import static javafx.scene.input.KeyCode.C; | |
| 30 | import static javafx.scene.input.KeyCode.INSERT; | |
| 31 | import static javafx.scene.input.KeyCombination.CONTROL_ANY; | |
| 32 | import static javafx.stage.Modality.NONE; | |
| 33 | ||
| 34 | /** | |
| 35 | * Responsible for logging application issues to {@link TableView} entries. | |
| 36 | */ | |
| 37 | public class LogView extends Alert { | |
| 38 | /** | |
| 39 | * Number of error messages to retain in the {@link TableView}, must be | |
| 40 | * greater than zero. | |
| 41 | */ | |
| 42 | private static final int CACHE_SIZE = 150; | |
| 43 | ||
| 44 | private final ObservableList<LogEntry> mEntries = observableArrayList(); | |
| 45 | private final TableView<LogEntry> mTable = new TableView<>( mEntries ); | |
| 46 | ||
| 47 | public LogView() { | |
| 48 | super( INFORMATION ); | |
| 49 | setTitle( get( "App.action.view.issues.text" ) ); | |
| 50 | initModality( NONE ); | |
| 51 | initTableView(); | |
| 52 | setResizable( true ); | |
| 53 | initButtons(); | |
| 54 | initIcon(); | |
| 55 | initActions(); | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * Brings the dialog to the foreground, showing it if needed. | |
| 60 | */ | |
| 61 | public void view() { | |
| 62 | super.show(); | |
| 63 | getStage().toFront(); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Removes all the entries from the list. | |
| 68 | */ | |
| 69 | public void clear() { | |
| 70 | mEntries.clear(); | |
| 71 | } | |
| 72 | ||
| 73 | public void log( final String message ) { | |
| 74 | log( new LogEntry( message ) ); | |
| 75 | } | |
| 76 | ||
| 77 | public void log( final Throwable error ) { | |
| 78 | log( new LogEntry( error ) ); | |
| 79 | } | |
| 80 | ||
| 81 | public void log( final String message, final Throwable trace ) { | |
| 82 | log( new LogEntry( message, trace ) ); | |
| 83 | } | |
| 84 | ||
| 85 | private void log( final LogEntry logEntry ) { | |
| 86 | mEntries.add( logEntry ); | |
| 87 | ||
| 88 | while( mEntries.size() > CACHE_SIZE ) { | |
| 89 | mEntries.remove( 0 ); | |
| 90 | } | |
| 91 | ||
| 92 | mTable.scrollTo( logEntry ); | |
| 93 | } | |
| 94 | ||
| 95 | private void initTableView() { | |
| 96 | final var ctrlC = new KeyCodeCombination( C, CONTROL_ANY ); | |
| 97 | final var ctrlInsert = new KeyCodeCombination( INSERT, CONTROL_ANY ); | |
| 98 | ||
| 99 | final var colDate = new TableColumn<LogEntry, String>( "Date" ); | |
| 100 | final var colMessage = new TableColumn<LogEntry, String>( "Message" ); | |
| 101 | final var colTrace = new TableColumn<LogEntry, String>( "Trace" ); | |
| 102 | ||
| 103 | colDate.setCellValueFactory( log -> log.getValue().dateProperty() ); | |
| 104 | colMessage.setCellValueFactory( log -> log.getValue().messageProperty() ); | |
| 105 | colTrace.setCellValueFactory( log -> log.getValue().traceProperty() ); | |
| 106 | ||
| 107 | final var columns = mTable.getColumns(); | |
| 108 | columns.add( colDate ); | |
| 109 | columns.add( colMessage ); | |
| 110 | columns.add( colTrace ); | |
| 111 | ||
| 112 | mTable.setMaxWidth( Double.MAX_VALUE ); | |
| 113 | mTable.setPrefWidth( 1024 ); | |
| 114 | mTable.getSelectionModel().setSelectionMode( MULTIPLE ); | |
| 115 | mTable.setOnKeyPressed( event -> { | |
| 116 | if( ctrlC.match( event ) || ctrlInsert.match( event ) ) { | |
| 117 | copyToClipboard( mTable ); | |
| 118 | } | |
| 119 | } ); | |
| 120 | ||
| 121 | final var pane = getDialogPane(); | |
| 122 | pane.setContent( mTable ); | |
| 123 | } | |
| 124 | ||
| 125 | private void initButtons() { | |
| 126 | final var pane = getDialogPane(); | |
| 127 | final var CLEAR = new ButtonType( "CLEAR" ); | |
| 128 | pane.getButtonTypes().add( CLEAR ); | |
| 129 | ||
| 130 | final var buttonOk = (Button) pane.lookupButton( OK ); | |
| 131 | final var buttonClear = (Button) pane.lookupButton( CLEAR ); | |
| 132 | ||
| 133 | buttonOk.setDefaultButton( true ); | |
| 134 | buttonClear.addEventFilter( ACTION, event -> { | |
| 135 | clear(); | |
| 136 | event.consume(); | |
| 137 | } ); | |
| 138 | ||
| 139 | pane.setOnKeyReleased( t -> { | |
| 140 | switch( t.getCode() ) { | |
| 141 | case ENTER, ESCAPE -> buttonOk.fire(); | |
| 142 | } | |
| 143 | } ); | |
| 144 | } | |
| 145 | ||
| 146 | private void initIcon() { | |
| 147 | final var stage = getStage(); | |
| 148 | stage.getIcons().add( ICON_DIALOG ); | |
| 149 | } | |
| 150 | ||
| 151 | private void initActions() { | |
| 152 | final var stage = getStage(); | |
| 153 | stage.setOnCloseRequest( event -> stage.hide() ); | |
| 154 | } | |
| 155 | ||
| 156 | private Stage getStage() { | |
| 157 | return (Stage) getDialogPane().getScene().getWindow(); | |
| 158 | } | |
| 159 | ||
| 160 | private static final class LogEntry { | |
| 161 | private final StringProperty mDate; | |
| 162 | private final StringProperty mMessage; | |
| 163 | private final StringProperty mTrace; | |
| 164 | ||
| 165 | /** | |
| 166 | * Constructs a new {@link LogEntry} for the current time, and having | |
| 167 | * no associated stack trace. | |
| 168 | * | |
| 169 | * @param message The error message. | |
| 170 | */ | |
| 171 | public LogEntry( final String message ) { | |
| 172 | this( message, null ); | |
| 173 | } | |
| 174 | ||
| 175 | /** | |
| 176 | * Constructs a new {@link LogEntry} for the current time, and using | |
| 177 | * the given error's message. | |
| 178 | * | |
| 179 | * @param error The stack trace, must not be {@code null}. | |
| 180 | */ | |
| 181 | public LogEntry( final Throwable error ) { | |
| 182 | this( error.getMessage(), error ); | |
| 183 | } | |
| 184 | ||
| 185 | /** | |
| 186 | * Constructs a new {@link LogEntry} with the current date and time. | |
| 187 | * | |
| 188 | * @param message The error message. | |
| 189 | * @param trace The stack trace associated with the message, may be | |
| 190 | * {@code null}. | |
| 191 | */ | |
| 192 | public LogEntry( final String message, final Throwable trace ) { | |
| 193 | mDate = new SimpleStringProperty( toString( now() ) ); | |
| 194 | mMessage = new SimpleStringProperty( message ); | |
| 195 | mTrace = new SimpleStringProperty( toString( trace ) ); | |
| 196 | } | |
| 197 | ||
| 198 | private StringProperty messageProperty() { | |
| 199 | return mMessage; | |
| 200 | } | |
| 201 | ||
| 202 | private StringProperty dateProperty() { | |
| 203 | return mDate; | |
| 204 | } | |
| 205 | ||
| 206 | private StringProperty traceProperty() { | |
| 207 | return mTrace; | |
| 208 | } | |
| 209 | ||
| 210 | private String toString( final LocalDateTime date ) { | |
| 211 | return date.format( ofPattern( "d MMM u HH:mm:ss" ) ); | |
| 212 | } | |
| 213 | ||
| 214 | private String toString( final Throwable trace ) { | |
| 215 | final var sb = new StringBuilder( 256 ); | |
| 216 | ||
| 217 | if( trace != null ) { | |
| 218 | sb.append( trace.getMessage().trim() ).append( NEWLINE ); | |
| 219 | stream( trace.getStackTrace() ) | |
| 220 | .takeWhile( LogView::filter ) | |
| 221 | .limit( 10 ) | |
| 222 | .collect( Collectors.toList() ) | |
| 223 | .forEach( e -> sb.append( e.toString() ).append( NEWLINE ) ); | |
| 224 | } | |
| 225 | ||
| 226 | return sb.toString(); | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | private static boolean filter( final StackTraceElement e ) { | |
| 231 | final var clazz = e.getClassName(); | |
| 232 | return clazz.startsWith( MainApp.class.getPackageName() ) || | |
| 233 | clazz.startsWith( "org.renjin" ); | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * Copies the contents of the selected rows into the clipboard; code is from | |
| 238 | * <a href="https://stackoverflow.com/a/48126059/59087">StackOverflow</a>. | |
| 239 | * | |
| 240 | * @param table The {@link TableView} having selected rows to copy. | |
| 241 | */ | |
| 242 | public void copyToClipboard( final TableView<?> table ) { | |
| 243 | final var sb = new StringBuilder(); | |
| 244 | final var rows = new TreeSet<Integer>(); | |
| 245 | boolean firstRow = true; | |
| 246 | ||
| 247 | for( final var position : table.getSelectionModel().getSelectedCells() ) { | |
| 248 | rows.add( position.getRow() ); | |
| 249 | } | |
| 250 | ||
| 251 | for( final var row : rows ) { | |
| 252 | if( !firstRow ) { | |
| 253 | sb.append( '\n' ); | |
| 254 | } | |
| 255 | ||
| 256 | firstRow = false; | |
| 257 | boolean firstCol = true; | |
| 258 | ||
| 259 | for( final var column : table.getColumns() ) { | |
| 260 | if( !firstCol ) { | |
| 261 | sb.append( '\t' ); | |
| 262 | } | |
| 263 | ||
| 264 | firstCol = false; | |
| 265 | final var data = column.getCellData( row ); | |
| 266 | sb.append( data == null ? "" : data.toString() ); | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | final var contents = new ClipboardContent(); | |
| 271 | contents.putString( sb.toString() ); | |
| 272 | getSystemClipboard().setContent( contents ); | |
| 273 | } | |
| 274 | } | |
| 1 | 275 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 | |
| ... | ||
| 13 | 13 | |
| 14 | 14 | import static com.keenwrite.Constants.FONT_DIRECTORY; |
| 15 | import static com.keenwrite.StatusBarNotifier.clue; | |
| 15 | import static com.keenwrite.StatusNotifier.clue; | |
| 16 | 16 | import static com.keenwrite.util.ProtocolScheme.valueFrom; |
| 17 | 17 | import static com.keenwrite.util.ResourceWalker.GLOB_FONTS; |
| ... | ||
| 38 | 38 | * </p> |
| 39 | 39 | */ |
| 40 | @SuppressWarnings("unchecked") | |
| 40 | @SuppressWarnings( "unchecked" ) | |
| 41 | 41 | public static void initFonts() { |
| 42 | final var ge = getLocalGraphicsEnvironment(); | |
| 43 | ||
| 44 | 42 | try { |
| 43 | final var ge = getLocalGraphicsEnvironment(); | |
| 45 | 44 | walk( |
| 46 | FONT_DIRECTORY, GLOB_FONTS, path -> { | |
| 47 | final var uri = path.toUri(); | |
| 48 | final var filename = path.toString(); | |
| 45 | FONT_DIRECTORY, GLOB_FONTS, path -> { | |
| 46 | final var uri = path.toUri(); | |
| 47 | final var filename = path.toString(); | |
| 49 | 48 | |
| 50 | try( final var is = openFont( uri, filename ) ) { | |
| 51 | final var font = createFont( TRUETYPE_FONT, is ); | |
| 52 | final var attributes = | |
| 53 | (Map<TextAttribute, Integer>) font.getAttributes(); | |
| 49 | try( final var is = openFont( uri, filename ) ) { | |
| 50 | final var font = createFont( TRUETYPE_FONT, is ); | |
| 51 | final var attributes = | |
| 52 | (Map<TextAttribute, Integer>) font.getAttributes(); | |
| 54 | 53 | |
| 55 | attributes.put( LIGATURES, LIGATURES_ON ); | |
| 56 | attributes.put( KERNING, KERNING_ON ); | |
| 57 | ge.registerFont( font.deriveFont( attributes ) ); | |
| 58 | } catch( final Exception ex ) { | |
| 59 | clue( ex ); | |
| 60 | } | |
| 54 | attributes.put( LIGATURES, LIGATURES_ON ); | |
| 55 | attributes.put( KERNING, KERNING_ON ); | |
| 56 | ge.registerFont( font.deriveFont( attributes ) ); | |
| 57 | } catch( final Exception ex ) { | |
| 58 | clue( ex ); | |
| 61 | 59 | } |
| 60 | } | |
| 62 | 61 | ); |
| 63 | 62 | } catch( final Exception ex ) { |
| ... | ||
| 76 | 75 | */ |
| 77 | 76 | private static InputStream openFont( final URI uri, final String filename ) |
| 78 | throws IOException { | |
| 77 | throws IOException { | |
| 79 | 78 | return valueFrom( uri ).isJar() |
| 80 | ? FontLoader.class.getResourceAsStream( filename ) | |
| 81 | : new FileInputStream( filename ); | |
| 79 | ? FontLoader.class.getResourceAsStream( filename ) | |
| 80 | : new FileInputStream( filename ); | |
| 82 | 81 | } |
| 83 | 82 | } |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 | |
| 4 | 4 | import java.io.IOException; |
| 5 | import java.net.URI; | |
| 6 | 5 | import java.net.URISyntaxException; |
| 6 | import java.nio.file.FileSystem; | |
| 7 | 7 | import java.nio.file.Files; |
| 8 | 8 | import java.nio.file.Path; |
| 9 | 9 | import java.nio.file.Paths; |
| 10 | 10 | import java.util.function.Consumer; |
| 11 | 11 | |
| 12 | import static com.keenwrite.util.ProtocolScheme.JAR; | |
| 13 | import static com.keenwrite.util.ProtocolScheme.valueFrom; | |
| 12 | 14 | import static java.nio.file.FileSystems.getDefault; |
| 13 | 15 | import static java.nio.file.FileSystems.newFileSystem; |
| ... | ||
| 31 | 33 | */ |
| 32 | 34 | public static void walk( |
| 33 | final String directory, final String glob, final Consumer<Path> c ) | |
| 34 | throws URISyntaxException, IOException { | |
| 35 | final String directory, final String glob, final Consumer<Path> c ) | |
| 36 | throws URISyntaxException, IOException { | |
| 35 | 37 | final var resource = ResourceWalker.class.getResource( directory ); |
| 36 | 38 | final var matcher = getDefault().getPathMatcher( "glob:" + glob ); |
| 37 | 39 | |
| 38 | 40 | if( resource != null ) { |
| 39 | 41 | final var uri = resource.toURI(); |
| 40 | final var jar = uri.getScheme().equals( "jar" ); | |
| 41 | final var path = jar ? toFileSystem( uri, directory ) : Paths.get( uri ); | |
| 42 | final Path path; | |
| 43 | FileSystem fs = null; | |
| 44 | ||
| 45 | if( valueFrom( uri ) == JAR ) { | |
| 46 | fs = newFileSystem( uri, emptyMap() ); | |
| 47 | path = fs.getPath( directory ); | |
| 48 | } | |
| 49 | else { | |
| 50 | path = Paths.get( uri ); | |
| 51 | } | |
| 42 | 52 | |
| 43 | 53 | try( final var walk = Files.walk( path, 10 ) ) { |
| 44 | 54 | for( final var it = walk.iterator(); it.hasNext(); ) { |
| 45 | 55 | final Path p = it.next(); |
| 46 | 56 | if( matcher.matches( p ) ) { |
| 47 | 57 | c.accept( p ); |
| 48 | 58 | } |
| 49 | 59 | } |
| 60 | } finally { | |
| 61 | if( fs != null ) { fs.close(); } | |
| 50 | 62 | } |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | private static Path toFileSystem( final URI uri, final String directory ) | |
| 55 | throws IOException { | |
| 56 | try( final var fs = newFileSystem( uri, emptyMap() ) ) { | |
| 57 | return fs.getPath( directory ); | |
| 58 | 63 | } |
| 59 | 64 | } |
| 4 | 4 | -fx-font-family: 'Noto Sans'; |
| 5 | 5 | -fx-font-size: 11pt; |
| 6 | -fx-padding: 1em; | |
| 6 | -fx-padding: .25em; | |
| 7 | 7 | } |
| 8 | 8 |
| 33 | 33 | Main.status.export.success=Saved as {0} |
| 34 | 34 | |
| 35 | Main.status.error.parse={0} (near ${Main.status.text.offset} {1}) | |
| 36 | Main.status.error.def.blank=Move the caret to a word before inserting a definition | |
| 37 | Main.status.error.def.empty=Create a definition before inserting a definition | |
| 38 | Main.status.error.def.missing=No definition value found for ''{0}'' | |
| 39 | Main.status.error.r=Error with [{0}...]: {1} | |
| 40 | Main.status.error.file.missing=Not found: {0} | |
| 41 | ||
| 42 | Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}'' | |
| 43 | Main.status.error.messages.syntax=Missing ''}'' in ''{0}'' | |
| 44 | ||
| 45 | Main.status.error.undo=Cannot undo; beginning of undo history reached | |
| 46 | Main.status.error.redo=Cannot redo; end of redo history reached | |
| 47 | ||
| 48 | Main.status.image.request.init=Initializing HTTP request | |
| 49 | Main.status.image.request.fetch=Requesting content type from {0} | |
| 50 | Main.status.image.request.success=Detected content type ''{0}'' | |
| 51 | ||
| 52 | # ######################################################################## | |
| 53 | # Search Bar | |
| 54 | # ######################################################################## | |
| 55 | ||
| 56 | Main.search.stop.tooltip=Close search bar | |
| 57 | Main.search.stop.icon=CLOSE | |
| 58 | Main.search.next.tooltip=Find next match | |
| 59 | Main.search.next.icon=CHEVRON_DOWN | |
| 60 | Main.search.prev.tooltip=Find previous match | |
| 61 | Main.search.prev.icon=CHEVRON_UP | |
| 62 | Main.search.find.tooltip=Search document for text | |
| 63 | Main.search.find.icon=SEARCH | |
| 64 | Main.search.match.none=No matches | |
| 65 | Main.search.match.some={0} of {1} matches | |
| 66 | ||
| 67 | # ######################################################################## | |
| 68 | # Workspace preferences | |
| 69 | # ######################################################################## | |
| 70 | ||
| 71 | workspace.r=R | |
| 72 | workspace.r.script=Startup Script | |
| 73 | workspace.r.script.desc=Script runs prior to executing R statements within the document. | |
| 74 | workspace.r.dir=Working Directory | |
| 75 | workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script. | |
| 76 | workspace.r.dir.title=Directory | |
| 77 | workspace.r.delimiter.began=Delimiter Prefix | |
| 78 | workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted definitions. | |
| 79 | workspace.r.delimiter.began.title=Opening | |
| 80 | workspace.r.delimiter.ended=Delimiter Suffix | |
| 81 | workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted definitions. | |
| 82 | workspace.r.delimiter.ended.title=Closing | |
| 83 | ||
| 84 | workspace.images=Images | |
| 85 | workspace.images.dir=Relative Directory | |
| 86 | workspace.images.dir.desc=Path prepended to embedded images referenced using local file paths. | |
| 87 | workspace.images.dir.title=Directory | |
| 88 | workspace.images.order=Extensions | |
| 89 | workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces. | |
| 90 | workspace.images.order.title=Extensions | |
| 91 | ||
| 92 | workspace.definition=Definition | |
| 93 | workspace.definition.path=File name | |
| 94 | workspace.definition.path.desc=Absolute path to interpolated string definition. | |
| 95 | workspace.definition.path.title=Path | |
| 96 | workspace.definition.delimiter.began=Delimiter Prefix | |
| 97 | workspace.definition.delimiter.began.desc=Indicates when a definition key is starting. | |
| 98 | workspace.definition.delimiter.began.title=Opening | |
| 99 | workspace.definition.delimiter.ended=Delimiter Suffix | |
| 100 | workspace.definition.delimiter.ended.desc=Indicates when a definition key is ending. | |
| 101 | workspace.definition.delimiter.ended.title=Closing | |
| 102 | ||
| 103 | workspace.ui.font=Fonts | |
| 104 | workspace.ui.font.editor.size=Editor Font Size | |
| 105 | workspace.ui.font.editor.size.desc=Text editor font size. | |
| 106 | workspace.ui.font.editor.size.title=Points | |
| 107 | workspace.ui.font.preview.size=Preview Font Size | |
| 108 | workspace.ui.font.preview.size.desc=Preview pane font size. | |
| 109 | workspace.ui.font.preview.size.title=Points | |
| 110 | workspace.ui.font.locale=Locale | |
| 111 | workspace.ui.font.locale.desc=Character set for editing and previewing. | |
| 112 | workspace.ui.font.locale.title=Language | |
| 113 | ||
| 114 | # ######################################################################## | |
| 115 | # Definition Pane and its Tree View | |
| 116 | # ######################################################################## | |
| 117 | ||
| 118 | Definition.menu.add.default=Undefined | |
| 119 | ||
| 120 | # ######################################################################## | |
| 121 | # Definition Pane | |
| 122 | # ######################################################################## | |
| 123 | ||
| 124 | Pane.definition.node.root.title=Definitions | |
| 125 | ||
| 126 | # ######################################################################## | |
| 127 | # Failure messages with respect to YAML files. | |
| 128 | # ######################################################################## | |
| 129 | ||
| 130 | yaml.error.open=Could not open YAML file (ensure non-empty file). | |
| 131 | yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | |
| 132 | yaml.error.missing=Empty definition value for key ''{0}''. | |
| 133 | yaml.error.tree.form=Unassigned definition near ''{0}''. | |
| 134 | ||
| 135 | # ######################################################################## | |
| 136 | # Text Resource | |
| 137 | # ######################################################################## | |
| 138 | ||
| 139 | TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | |
| 140 | TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | |
| 141 | ||
| 142 | # ######################################################################## | |
| 143 | # Text Resources | |
| 144 | # ######################################################################## | |
| 145 | ||
| 146 | TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | |
| 147 | TextResource.saveFailed.title=Save | |
| 148 | ||
| 149 | # ######################################################################## | |
| 150 | # File Open | |
| 151 | # ######################################################################## | |
| 152 | ||
| 153 | Dialog.file.choose.open.title=Open File | |
| 154 | Dialog.file.choose.save.title=Save File | |
| 155 | Dialog.file.choose.export.title=Export File | |
| 156 | ||
| 157 | Dialog.file.choose.filter.title.source=Source Files | |
| 158 | Dialog.file.choose.filter.title.definition=Definition Files | |
| 159 | Dialog.file.choose.filter.title.xml=XML Files | |
| 160 | Dialog.file.choose.filter.title.all=All Files | |
| 161 | ||
| 162 | # ######################################################################## | |
| 163 | # Browse File | |
| 164 | # ######################################################################## | |
| 165 | ||
| 166 | BrowseFileButton.chooser.title=Browse for local file | |
| 167 | BrowseFileButton.chooser.allFilesFilter=All Files | |
| 168 | BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | |
| 169 | ||
| 170 | # ######################################################################## | |
| 171 | # Alert Dialog | |
| 172 | # ######################################################################## | |
| 173 | ||
| 174 | Alert.file.close.title=Close | |
| 175 | Alert.file.close.text=Save changes to {0}? | |
| 176 | ||
| 177 | # ######################################################################## | |
| 178 | # Image Dialog | |
| 179 | # ######################################################################## | |
| 180 | ||
| 181 | Dialog.image.title=Image | |
| 182 | Dialog.image.chooser.imagesFilter=Images | |
| 183 | Dialog.image.previewLabel.text=Markdown Preview\: | |
| 184 | Dialog.image.textLabel.text=Alternate Text\: | |
| 185 | Dialog.image.titleLabel.text=Title (tooltip)\: | |
| 186 | Dialog.image.urlLabel.text=Image URL\: | |
| 187 | ||
| 188 | # ######################################################################## | |
| 189 | # Hyperlink Dialog | |
| 190 | # ######################################################################## | |
| 191 | ||
| 192 | Dialog.link.title=Link | |
| 193 | Dialog.link.previewLabel.text=Markdown Preview\: | |
| 194 | Dialog.link.textLabel.text=Link Text\: | |
| 195 | Dialog.link.titleLabel.text=Title (tooltip)\: | |
| 196 | Dialog.link.urlLabel.text=Link URL\: | |
| 197 | ||
| 198 | # ######################################################################## | |
| 199 | # About Dialog | |
| 200 | # ######################################################################## | |
| 201 | ||
| 202 | Dialog.about.title=About {0} | |
| 203 | Dialog.about.header={0} | |
| 204 | Dialog.about.content=Copyright 2020 White Magic Software, Ltd.\n\nBased on Markdown Writer FX by Karl Tauber | |
| 205 | ||
| 206 | # ######################################################################## | |
| 207 | # Application Actions | |
| 208 | # ######################################################################## | |
| 209 | ||
| 210 | App.action.file.new.description=Create a new file | |
| 211 | App.action.file.new.accelerator=Shortcut+N | |
| 212 | App.action.file.new.icon=FILE_ALT | |
| 213 | App.action.file.new.text=_New | |
| 214 | ||
| 215 | App.action.file.open.description=Open a new file | |
| 216 | App.action.file.open.accelerator=Shortcut+O | |
| 217 | App.action.file.open.text=_Open... | |
| 218 | App.action.file.open.icon=FOLDER_OPEN_ALT | |
| 219 | ||
| 220 | App.action.file.close.description=Close the current document | |
| 221 | App.action.file.close.accelerator=Shortcut+W | |
| 222 | App.action.file.close.text=_Close | |
| 223 | ||
| 224 | App.action.file.close_all.description=Close all open documents | |
| 225 | App.action.file.close_all.accelerator=Ctrl+F4 | |
| 226 | App.action.file.close_all.text=Close All | |
| 227 | ||
| 228 | App.action.file.save.description=Save the document | |
| 229 | App.action.file.save.accelerator=Shortcut+S | |
| 230 | App.action.file.save.text=_Save | |
| 231 | App.action.file.save.icon=FLOPPY_ALT | |
| 232 | ||
| 233 | App.action.file.save_as.description=Rename the current document | |
| 234 | App.action.file.save_as.text=Save _As | |
| 235 | ||
| 236 | App.action.file.save_all.description=Save all open documents | |
| 237 | App.action.file.save_all.accelerator=Shortcut+Shift+S | |
| 238 | App.action.file.save_all.text=Save A_ll | |
| 239 | ||
| 240 | App.action.file.export.html_svg.description=Export the current document as HTML + SVG | |
| 241 | App.action.file.export.text=_Export As | |
| 242 | App.action.file.export.html_svg.text=HTML and S_VG | |
| 243 | ||
| 244 | App.action.file.export.html_tex.description=Export the current document as HTML + TeX | |
| 245 | App.action.file.export.html_tex.text=HTML and _TeX | |
| 246 | ||
| 247 | App.action.file.export.markdown.description=Export the current document as Markdown | |
| 248 | App.action.file.export.markdown.text=Markdown | |
| 249 | ||
| 250 | App.action.file.exit.description=Quit the application | |
| 251 | App.action.file.exit.text=E_xit | |
| 252 | ||
| 253 | ||
| 254 | App.action.edit.undo.description=Undo the previous edit | |
| 255 | App.action.edit.undo.accelerator=Shortcut+Z | |
| 256 | App.action.edit.undo.text=_Undo | |
| 257 | App.action.edit.undo.icon=UNDO | |
| 258 | ||
| 259 | App.action.edit.redo.description=Redo the previous edit | |
| 260 | App.action.edit.redo.accelerator=Shortcut+Y | |
| 261 | App.action.edit.redo.text=_Redo | |
| 262 | App.action.edit.redo.icon=REPEAT | |
| 263 | ||
| 264 | App.action.edit.cut.description=Delete the selected text or line | |
| 265 | App.action.edit.cut.accelerator=Shortcut+X | |
| 266 | App.action.edit.cut.text=Cu_t | |
| 267 | App.action.edit.cut.icon=CUT | |
| 268 | ||
| 269 | App.action.edit.copy.description=Copy the selected text | |
| 270 | App.action.edit.copy.accelerator=Shortcut+C | |
| 271 | App.action.edit.copy.text=_Copy | |
| 272 | App.action.edit.copy.icon=COPY | |
| 273 | ||
| 274 | App.action.edit.paste.description=Paste from the clipboard | |
| 275 | App.action.edit.paste.accelerator=Shortcut+V | |
| 276 | App.action.edit.paste.text=_Paste | |
| 277 | App.action.edit.paste.icon=PASTE | |
| 278 | ||
| 279 | App.action.edit.select_all.description=Highlight the current document text | |
| 280 | App.action.edit.select_all.accelerator=Shortcut+A | |
| 281 | App.action.edit.select_all.text=Select _All | |
| 282 | ||
| 283 | App.action.edit.find.description=Search for text in the document | |
| 284 | App.action.edit.find.accelerator=Shortcut+F | |
| 285 | App.action.edit.find.text=_Find | |
| 286 | App.action.edit.find.icon=SEARCH | |
| 287 | ||
| 288 | App.action.edit.find_next.description=Find next occurrence | |
| 289 | App.action.edit.find_next.accelerator=F3 | |
| 290 | App.action.edit.find_next.text=Find _Next | |
| 291 | ||
| 292 | App.action.edit.find_prev.description=Find previous occurrence | |
| 293 | App.action.edit.find_prev.accelerator=Shift+F3 | |
| 294 | App.action.edit.find_prev.text=Find _Prev | |
| 295 | ||
| 296 | App.action.edit.preferences.description=Edit user preferences | |
| 297 | App.action.edit.preferences.accelerator=Ctrl+Alt+S | |
| 298 | App.action.edit.preferences.text=_Preferences | |
| 299 | ||
| 300 | ||
| 301 | App.action.format.bold.description=Insert strong text | |
| 302 | App.action.format.bold.accelerator=Shortcut+B | |
| 303 | App.action.format.bold.text=_Bold | |
| 304 | App.action.format.bold.icon=BOLD | |
| 305 | ||
| 306 | App.action.format.italic.description=Insert text emphasis | |
| 307 | App.action.format.italic.accelerator=Shortcut+I | |
| 308 | App.action.format.italic.text=_Italic | |
| 309 | App.action.format.italic.icon=ITALIC | |
| 310 | ||
| 311 | App.action.format.superscript.description=Insert superscript text | |
| 312 | App.action.format.superscript.accelerator=Shortcut+[ | |
| 313 | App.action.format.superscript.text=Su_perscript | |
| 314 | App.action.format.superscript.icon=SUPERSCRIPT | |
| 315 | ||
| 316 | App.action.format.subscript.description=Insert subscript text | |
| 317 | App.action.format.subscript.accelerator=Shortcut+] | |
| 318 | App.action.format.subscript.text=Su_bscript | |
| 319 | App.action.format.subscript.icon=SUBSCRIPT | |
| 320 | ||
| 321 | App.action.format.strikethrough.description=Insert struck text | |
| 322 | App.action.format.strikethrough.accelerator=Shortcut+T | |
| 323 | App.action.format.strikethrough.text=Stri_kethrough | |
| 324 | App.action.format.strikethrough.icon=STRIKETHROUGH | |
| 325 | ||
| 326 | ||
| 327 | App.action.insert.blockquote.description=Insert blockquote | |
| 328 | App.action.insert.blockquote.accelerator=Ctrl+Q | |
| 329 | App.action.insert.blockquote.text=_Blockquote | |
| 330 | App.action.insert.blockquote.icon=QUOTE_LEFT | |
| 331 | ||
| 332 | App.action.insert.code.description=Insert inline code | |
| 333 | App.action.insert.code.accelerator=Shortcut+K | |
| 334 | App.action.insert.code.text=Inline _Code | |
| 335 | App.action.insert.code.icon=CODE | |
| 336 | ||
| 337 | App.action.insert.fenced_code_block.description=Insert code block | |
| 338 | App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K | |
| 339 | App.action.insert.fenced_code_block.text=_Fenced Code Block | |
| 340 | App.action.insert.fenced_code_block.prompt.text=Enter code here | |
| 341 | App.action.insert.fenced_code_block.icon=FILE_CODE_ALT | |
| 342 | ||
| 343 | App.action.insert.link.description=Insert hyperlink | |
| 344 | App.action.insert.link.accelerator=Shortcut+L | |
| 345 | App.action.insert.link.text=_Link... | |
| 346 | App.action.insert.link.icon=LINK | |
| 347 | ||
| 348 | App.action.insert.image.description=Insert image | |
| 349 | App.action.insert.image.accelerator=Shortcut+G | |
| 350 | App.action.insert.image.text=_Image... | |
| 351 | App.action.insert.image.icon=PICTURE_ALT | |
| 352 | ||
| 353 | App.action.insert.heading.description=Insert heading level | |
| 354 | App.action.insert.heading.accelerator=Shortcut+ | |
| 355 | App.action.insert.heading.icon=HEADER | |
| 356 | ||
| 357 | App.action.insert.heading_1.description=${App.action.insert.heading.description} 1 | |
| 358 | App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1 | |
| 359 | App.action.insert.heading_1.text=Heading _1 | |
| 360 | App.action.insert.heading_1.icon=${App.action.insert.heading.icon} | |
| 361 | ||
| 362 | App.action.insert.heading_2.description=${App.action.insert.heading.description} 2 | |
| 363 | App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2 | |
| 364 | App.action.insert.heading_2.text=Heading _2 | |
| 365 | App.action.insert.heading_2.icon=${App.action.insert.heading.icon} | |
| 366 | ||
| 367 | App.action.insert.heading_3.description=${App.action.insert.heading.description} 3 | |
| 368 | App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3 | |
| 369 | App.action.insert.heading_3.text=Heading _3 | |
| 370 | App.action.insert.heading_3.icon=${App.action.insert.heading.icon} | |
| 371 | ||
| 372 | App.action.insert.unordered_list.description=Insert bulleted list | |
| 373 | App.action.insert.unordered_list.accelerator=Shortcut+U | |
| 374 | App.action.insert.unordered_list.text=_Unordered List | |
| 375 | App.action.insert.unordered_list.icon=LIST_UL | |
| 376 | ||
| 377 | App.action.insert.ordered_list.description=Insert enumerated list | |
| 378 | App.action.insert.ordered_list.accelerator=Shortcut+Shift+O | |
| 379 | App.action.insert.ordered_list.text=_Ordered List | |
| 380 | App.action.insert.ordered_list.icon=LIST_OL | |
| 381 | ||
| 382 | App.action.insert.horizontal_rule.description=Insert horizontal rule | |
| 383 | App.action.insert.horizontal_rule.accelerator=Shortcut+H | |
| 384 | App.action.insert.horizontal_rule.text=_Horizontal Rule | |
| 385 | App.action.insert.horizontal_rule.icon=LIST_OL | |
| 386 | ||
| 387 | ||
| 388 | App.action.definition.create.description=Create a new variable definition | |
| 389 | App.action.definition.create.text=_Create | |
| 390 | App.action.definition.create.icon=TREE | |
| 391 | App.action.definition.create.tooltip=Add new item (Insert) | |
| 392 | ||
| 393 | App.action.definition.rename.description=Rename the selected variable definition | |
| 394 | App.action.definition.rename.text=_Rename | |
| 395 | App.action.definition.rename.icon=EDIT | |
| 396 | App.action.definition.rename.tooltip=Rename selected item (F2) | |
| 397 | ||
| 398 | App.action.definition.delete.description=Delete the selected variable definitions | |
| 399 | App.action.definition.delete.text=_Delete | |
| 400 | App.action.definition.delete.icon=TRASH | |
| 401 | App.action.definition.delete.tooltip=Delete selected items (Delete) | |
| 402 | ||
| 403 | App.action.definition.insert.description=Insert a definition | |
| 404 | App.action.definition.insert.accelerator=Ctrl+Space | |
| 405 | App.action.definition.insert.text=_Insert | |
| 406 | App.action.definition.insert.icon=STAR | |
| 407 | ||
| 408 | ||
| 409 | App.action.view.refresh.description=Clear all caches | |
| 410 | App.action.view.refresh.accelerator=F5 | |
| 411 | App.action.view.refresh.text=Refresh | |
| 412 | ||
| 413 | App.action.view.preview.description=Open document preview | |
| 414 | App.action.view.preview.accelerator=F7 | |
| 415 | App.action.view.preview.text=Preview | |
| 416 | ||
| 417 | App.action.view.outline.description=Open document outline | |
| 418 | App.action.view.outline.accelerator=F8 | |
| 419 | App.action.view.outline.text=Outline | |
| 420 | ||
| 35 | Main.status.error.bootstrap.eval=Note: Bootstrap definition of ''{0}'' not found | |
| 36 | ||
| 37 | Main.status.error.parse={0} (near ${Main.status.text.offset} {1}) | |
| 38 | Main.status.error.def.blank=Move the caret to a word before inserting a definition | |
| 39 | Main.status.error.def.empty=Create a definition before inserting a definition | |
| 40 | Main.status.error.def.missing=No definition value found for ''{0}'' | |
| 41 | Main.status.error.r=Error with [{0}...]: {1} | |
| 42 | Main.status.error.file.missing=Not found: {0} | |
| 43 | ||
| 44 | Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}'' | |
| 45 | Main.status.error.messages.syntax=Missing ''}'' in ''{0}'' | |
| 46 | ||
| 47 | Main.status.error.undo=Cannot undo; beginning of undo history reached | |
| 48 | Main.status.error.redo=Cannot redo; end of redo history reached | |
| 49 | ||
| 50 | Main.status.image.request.init=Initializing HTTP request | |
| 51 | Main.status.image.request.fetch=Requesting content type from {0} | |
| 52 | Main.status.image.request.success=Detected content type ''{0}'' | |
| 53 | ||
| 54 | Main.status.font.search.missing=No font name starting with ''{0}'' was found | |
| 55 | ||
| 56 | # ######################################################################## | |
| 57 | # Search Bar | |
| 58 | # ######################################################################## | |
| 59 | ||
| 60 | Main.search.stop.tooltip=Close search bar | |
| 61 | Main.search.stop.icon=CLOSE | |
| 62 | Main.search.next.tooltip=Find next match | |
| 63 | Main.search.next.icon=CHEVRON_DOWN | |
| 64 | Main.search.prev.tooltip=Find previous match | |
| 65 | Main.search.prev.icon=CHEVRON_UP | |
| 66 | Main.search.find.tooltip=Search document for text | |
| 67 | Main.search.find.icon=SEARCH | |
| 68 | Main.search.match.none=No matches | |
| 69 | Main.search.match.some={0} of {1} matches | |
| 70 | ||
| 71 | # ######################################################################## | |
| 72 | # Workspace preferences | |
| 73 | # ######################################################################## | |
| 74 | ||
| 75 | workspace.r=R | |
| 76 | workspace.r.script=Startup Script | |
| 77 | workspace.r.script.desc=Script runs prior to executing R statements within the document. | |
| 78 | workspace.r.dir=Working Directory | |
| 79 | workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script. | |
| 80 | workspace.r.dir.title=Directory | |
| 81 | workspace.r.delimiter.began=Delimiter Prefix | |
| 82 | workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted definitions. | |
| 83 | workspace.r.delimiter.began.title=Opening | |
| 84 | workspace.r.delimiter.ended=Delimiter Suffix | |
| 85 | workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted definitions. | |
| 86 | workspace.r.delimiter.ended.title=Closing | |
| 87 | ||
| 88 | workspace.images=Images | |
| 89 | workspace.images.dir=Relative Directory | |
| 90 | workspace.images.dir.desc=Path prepended to embedded images referenced using local file paths. | |
| 91 | workspace.images.dir.title=Directory | |
| 92 | workspace.images.order=Extensions | |
| 93 | workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces. | |
| 94 | workspace.images.order.title=Extensions | |
| 95 | ||
| 96 | workspace.definition=Definition | |
| 97 | workspace.definition.path=File name | |
| 98 | workspace.definition.path.desc=Absolute path to interpolated string definition. | |
| 99 | workspace.definition.path.title=Path | |
| 100 | workspace.definition.delimiter.began=Delimiter Prefix | |
| 101 | workspace.definition.delimiter.began.desc=Indicates when a definition key is starting. | |
| 102 | workspace.definition.delimiter.began.title=Opening | |
| 103 | workspace.definition.delimiter.ended=Delimiter Suffix | |
| 104 | workspace.definition.delimiter.ended.desc=Indicates when a definition key is ending. | |
| 105 | workspace.definition.delimiter.ended.title=Closing | |
| 106 | ||
| 107 | workspace.ui.font=Fonts | |
| 108 | workspace.ui.font.editor=Editor Font | |
| 109 | workspace.ui.font.editor.name=Name | |
| 110 | workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended). | |
| 111 | workspace.ui.font.editor.name.title=Family | |
| 112 | workspace.ui.font.editor.size=Size | |
| 113 | workspace.ui.font.editor.size.desc=Font size. | |
| 114 | workspace.ui.font.editor.size.title=Points | |
| 115 | workspace.ui.font.preview=Preview Font | |
| 116 | workspace.ui.font.preview.name=Name | |
| 117 | workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended). | |
| 118 | workspace.ui.font.preview.name.title=Family | |
| 119 | workspace.ui.font.preview.size=Size | |
| 120 | workspace.ui.font.preview.size.desc=Font size. | |
| 121 | workspace.ui.font.preview.size.title=Points | |
| 122 | workspace.ui.font.preview.mono.name=Name | |
| 123 | workspace.ui.font.preview.mono.name.desc=Monospace font name. | |
| 124 | workspace.ui.font.preview.mono.name.title=Family | |
| 125 | workspace.ui.font.preview.mono.size=Size | |
| 126 | workspace.ui.font.preview.mono.size.desc=Monospace font size. | |
| 127 | workspace.ui.font.preview.mono.size.title=Points | |
| 128 | ||
| 129 | workspace.language=Language | |
| 130 | workspace.language.locale=Internationalization | |
| 131 | workspace.language.locale.desc=Language for application and HTML export. | |
| 132 | workspace.language.locale.title=Locale | |
| 133 | ||
| 134 | # ######################################################################## | |
| 135 | # Definition Pane and its Tree View | |
| 136 | # ######################################################################## | |
| 137 | ||
| 138 | Definition.menu.add.default=Undefined | |
| 139 | ||
| 140 | # ######################################################################## | |
| 141 | # Definition Pane | |
| 142 | # ######################################################################## | |
| 143 | ||
| 144 | Pane.definition.node.root.title=Definitions | |
| 145 | ||
| 146 | # ######################################################################## | |
| 147 | # Failure messages with respect to YAML files. | |
| 148 | # ######################################################################## | |
| 149 | ||
| 150 | yaml.error.open=Could not open YAML file (ensure non-empty file). | |
| 151 | yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | |
| 152 | yaml.error.missing=Empty definition value for key ''{0}''. | |
| 153 | yaml.error.tree.form=Unassigned definition near ''{0}''. | |
| 154 | ||
| 155 | # ######################################################################## | |
| 156 | # Text Resource | |
| 157 | # ######################################################################## | |
| 158 | ||
| 159 | TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | |
| 160 | TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | |
| 161 | ||
| 162 | # ######################################################################## | |
| 163 | # Text Resources | |
| 164 | # ######################################################################## | |
| 165 | ||
| 166 | TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | |
| 167 | TextResource.saveFailed.title=Save | |
| 168 | ||
| 169 | # ######################################################################## | |
| 170 | # File Open | |
| 171 | # ######################################################################## | |
| 172 | ||
| 173 | Dialog.file.choose.open.title=Open File | |
| 174 | Dialog.file.choose.save.title=Save File | |
| 175 | Dialog.file.choose.export.title=Export File | |
| 176 | ||
| 177 | Dialog.file.choose.filter.title.source=Source Files | |
| 178 | Dialog.file.choose.filter.title.definition=Definition Files | |
| 179 | Dialog.file.choose.filter.title.xml=XML Files | |
| 180 | Dialog.file.choose.filter.title.all=All Files | |
| 181 | ||
| 182 | # ######################################################################## | |
| 183 | # Browse File | |
| 184 | # ######################################################################## | |
| 185 | ||
| 186 | BrowseFileButton.chooser.title=Browse for local file | |
| 187 | BrowseFileButton.chooser.allFilesFilter=All Files | |
| 188 | BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | |
| 189 | ||
| 190 | # ######################################################################## | |
| 191 | # Alert Dialog | |
| 192 | # ######################################################################## | |
| 193 | ||
| 194 | Alert.file.close.title=Close | |
| 195 | Alert.file.close.text=Save changes to {0}? | |
| 196 | ||
| 197 | # ######################################################################## | |
| 198 | # Image Dialog | |
| 199 | # ######################################################################## | |
| 200 | ||
| 201 | Dialog.image.title=Image | |
| 202 | Dialog.image.chooser.imagesFilter=Images | |
| 203 | Dialog.image.previewLabel.text=Markdown Preview\: | |
| 204 | Dialog.image.textLabel.text=Alternate Text\: | |
| 205 | Dialog.image.titleLabel.text=Title (tooltip)\: | |
| 206 | Dialog.image.urlLabel.text=Image URL\: | |
| 207 | ||
| 208 | # ######################################################################## | |
| 209 | # Hyperlink Dialog | |
| 210 | # ######################################################################## | |
| 211 | ||
| 212 | Dialog.link.title=Link | |
| 213 | Dialog.link.previewLabel.text=Markdown Preview\: | |
| 214 | Dialog.link.textLabel.text=Link Text\: | |
| 215 | Dialog.link.titleLabel.text=Title (tooltip)\: | |
| 216 | Dialog.link.urlLabel.text=Link URL\: | |
| 217 | ||
| 218 | # ######################################################################## | |
| 219 | # About Dialog | |
| 220 | # ######################################################################## | |
| 221 | ||
| 222 | Dialog.about.title=About {0} | |
| 223 | Dialog.about.header={0} | |
| 224 | Dialog.about.content=Copyright 2020 White Magic Software, Ltd.\n\nBased on Markdown Writer FX by Karl Tauber | |
| 225 | ||
| 226 | # ######################################################################## | |
| 227 | # Application Actions | |
| 228 | # ######################################################################## | |
| 229 | ||
| 230 | App.action.file.new.description=Create a new file | |
| 231 | App.action.file.new.accelerator=Shortcut+N | |
| 232 | App.action.file.new.icon=FILE_ALT | |
| 233 | App.action.file.new.text=_New | |
| 234 | ||
| 235 | App.action.file.open.description=Open a new file | |
| 236 | App.action.file.open.accelerator=Shortcut+O | |
| 237 | App.action.file.open.text=_Open... | |
| 238 | App.action.file.open.icon=FOLDER_OPEN_ALT | |
| 239 | ||
| 240 | App.action.file.close.description=Close the current document | |
| 241 | App.action.file.close.accelerator=Shortcut+W | |
| 242 | App.action.file.close.text=_Close | |
| 243 | ||
| 244 | App.action.file.close_all.description=Close all open documents | |
| 245 | App.action.file.close_all.accelerator=Ctrl+F4 | |
| 246 | App.action.file.close_all.text=Close All | |
| 247 | ||
| 248 | App.action.file.save.description=Save the document | |
| 249 | App.action.file.save.accelerator=Shortcut+S | |
| 250 | App.action.file.save.text=_Save | |
| 251 | App.action.file.save.icon=FLOPPY_ALT | |
| 252 | ||
| 253 | App.action.file.save_as.description=Rename the current document | |
| 254 | App.action.file.save_as.text=Save _As | |
| 255 | ||
| 256 | App.action.file.save_all.description=Save all open documents | |
| 257 | App.action.file.save_all.accelerator=Shortcut+Shift+S | |
| 258 | App.action.file.save_all.text=Save A_ll | |
| 259 | ||
| 260 | App.action.file.export.html_svg.description=Export the current document as HTML + SVG | |
| 261 | App.action.file.export.text=_Export As | |
| 262 | App.action.file.export.html_svg.text=HTML and S_VG | |
| 263 | ||
| 264 | App.action.file.export.html_tex.description=Export the current document as HTML + TeX | |
| 265 | App.action.file.export.html_tex.text=HTML and _TeX | |
| 266 | ||
| 267 | App.action.file.export.markdown.description=Export the current document as Markdown | |
| 268 | App.action.file.export.markdown.text=Markdown | |
| 269 | ||
| 270 | App.action.file.exit.description=Quit the application | |
| 271 | App.action.file.exit.text=E_xit | |
| 272 | ||
| 273 | ||
| 274 | App.action.edit.undo.description=Undo the previous edit | |
| 275 | App.action.edit.undo.accelerator=Shortcut+Z | |
| 276 | App.action.edit.undo.text=_Undo | |
| 277 | App.action.edit.undo.icon=UNDO | |
| 278 | ||
| 279 | App.action.edit.redo.description=Redo the previous edit | |
| 280 | App.action.edit.redo.accelerator=Shortcut+Y | |
| 281 | App.action.edit.redo.text=_Redo | |
| 282 | App.action.edit.redo.icon=REPEAT | |
| 283 | ||
| 284 | App.action.edit.cut.description=Delete the selected text or line | |
| 285 | App.action.edit.cut.accelerator=Shortcut+X | |
| 286 | App.action.edit.cut.text=Cu_t | |
| 287 | App.action.edit.cut.icon=CUT | |
| 288 | ||
| 289 | App.action.edit.copy.description=Copy the selected text | |
| 290 | App.action.edit.copy.accelerator=Shortcut+C | |
| 291 | App.action.edit.copy.text=_Copy | |
| 292 | App.action.edit.copy.icon=COPY | |
| 293 | ||
| 294 | App.action.edit.paste.description=Paste from the clipboard | |
| 295 | App.action.edit.paste.accelerator=Shortcut+V | |
| 296 | App.action.edit.paste.text=_Paste | |
| 297 | App.action.edit.paste.icon=PASTE | |
| 298 | ||
| 299 | App.action.edit.select_all.description=Highlight the current document text | |
| 300 | App.action.edit.select_all.accelerator=Shortcut+A | |
| 301 | App.action.edit.select_all.text=Select _All | |
| 302 | ||
| 303 | App.action.edit.find.description=Search for text in the document | |
| 304 | App.action.edit.find.accelerator=Shortcut+F | |
| 305 | App.action.edit.find.text=_Find | |
| 306 | App.action.edit.find.icon=SEARCH | |
| 307 | ||
| 308 | App.action.edit.find_next.description=Find next occurrence | |
| 309 | App.action.edit.find_next.accelerator=F3 | |
| 310 | App.action.edit.find_next.text=Find _Next | |
| 311 | ||
| 312 | App.action.edit.find_prev.description=Find previous occurrence | |
| 313 | App.action.edit.find_prev.accelerator=Shift+F3 | |
| 314 | App.action.edit.find_prev.text=Find _Prev | |
| 315 | ||
| 316 | App.action.edit.preferences.description=Edit user preferences | |
| 317 | App.action.edit.preferences.accelerator=Ctrl+Alt+S | |
| 318 | App.action.edit.preferences.text=_Preferences | |
| 319 | ||
| 320 | ||
| 321 | App.action.format.bold.description=Insert strong text | |
| 322 | App.action.format.bold.accelerator=Shortcut+B | |
| 323 | App.action.format.bold.text=_Bold | |
| 324 | App.action.format.bold.icon=BOLD | |
| 325 | ||
| 326 | App.action.format.italic.description=Insert text emphasis | |
| 327 | App.action.format.italic.accelerator=Shortcut+I | |
| 328 | App.action.format.italic.text=_Italic | |
| 329 | App.action.format.italic.icon=ITALIC | |
| 330 | ||
| 331 | App.action.format.superscript.description=Insert superscript text | |
| 332 | App.action.format.superscript.accelerator=Shortcut+[ | |
| 333 | App.action.format.superscript.text=Su_perscript | |
| 334 | App.action.format.superscript.icon=SUPERSCRIPT | |
| 335 | ||
| 336 | App.action.format.subscript.description=Insert subscript text | |
| 337 | App.action.format.subscript.accelerator=Shortcut+] | |
| 338 | App.action.format.subscript.text=Su_bscript | |
| 339 | App.action.format.subscript.icon=SUBSCRIPT | |
| 340 | ||
| 341 | App.action.format.strikethrough.description=Insert struck text | |
| 342 | App.action.format.strikethrough.accelerator=Shortcut+T | |
| 343 | App.action.format.strikethrough.text=Stri_kethrough | |
| 344 | App.action.format.strikethrough.icon=STRIKETHROUGH | |
| 345 | ||
| 346 | ||
| 347 | App.action.insert.blockquote.description=Insert blockquote | |
| 348 | App.action.insert.blockquote.accelerator=Ctrl+Q | |
| 349 | App.action.insert.blockquote.text=_Blockquote | |
| 350 | App.action.insert.blockquote.icon=QUOTE_LEFT | |
| 351 | ||
| 352 | App.action.insert.code.description=Insert inline code | |
| 353 | App.action.insert.code.accelerator=Shortcut+K | |
| 354 | App.action.insert.code.text=Inline _Code | |
| 355 | App.action.insert.code.icon=CODE | |
| 356 | ||
| 357 | App.action.insert.fenced_code_block.description=Insert code block | |
| 358 | App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K | |
| 359 | App.action.insert.fenced_code_block.text=_Fenced Code Block | |
| 360 | App.action.insert.fenced_code_block.prompt.text=Enter code here | |
| 361 | App.action.insert.fenced_code_block.icon=FILE_CODE_ALT | |
| 362 | ||
| 363 | App.action.insert.link.description=Insert hyperlink | |
| 364 | App.action.insert.link.accelerator=Shortcut+L | |
| 365 | App.action.insert.link.text=_Link... | |
| 366 | App.action.insert.link.icon=LINK | |
| 367 | ||
| 368 | App.action.insert.image.description=Insert image | |
| 369 | App.action.insert.image.accelerator=Shortcut+G | |
| 370 | App.action.insert.image.text=_Image... | |
| 371 | App.action.insert.image.icon=PICTURE_ALT | |
| 372 | ||
| 373 | App.action.insert.heading.description=Insert heading level | |
| 374 | App.action.insert.heading.accelerator=Shortcut+ | |
| 375 | App.action.insert.heading.icon=HEADER | |
| 376 | ||
| 377 | App.action.insert.heading_1.description=${App.action.insert.heading.description} 1 | |
| 378 | App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1 | |
| 379 | App.action.insert.heading_1.text=Heading _1 | |
| 380 | App.action.insert.heading_1.icon=${App.action.insert.heading.icon} | |
| 381 | ||
| 382 | App.action.insert.heading_2.description=${App.action.insert.heading.description} 2 | |
| 383 | App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2 | |
| 384 | App.action.insert.heading_2.text=Heading _2 | |
| 385 | App.action.insert.heading_2.icon=${App.action.insert.heading.icon} | |
| 386 | ||
| 387 | App.action.insert.heading_3.description=${App.action.insert.heading.description} 3 | |
| 388 | App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3 | |
| 389 | App.action.insert.heading_3.text=Heading _3 | |
| 390 | App.action.insert.heading_3.icon=${App.action.insert.heading.icon} | |
| 391 | ||
| 392 | App.action.insert.unordered_list.description=Insert bulleted list | |
| 393 | App.action.insert.unordered_list.accelerator=Shortcut+U | |
| 394 | App.action.insert.unordered_list.text=_Unordered List | |
| 395 | App.action.insert.unordered_list.icon=LIST_UL | |
| 396 | ||
| 397 | App.action.insert.ordered_list.description=Insert enumerated list | |
| 398 | App.action.insert.ordered_list.accelerator=Shortcut+Shift+O | |
| 399 | App.action.insert.ordered_list.text=_Ordered List | |
| 400 | App.action.insert.ordered_list.icon=LIST_OL | |
| 401 | ||
| 402 | App.action.insert.horizontal_rule.description=Insert horizontal rule | |
| 403 | App.action.insert.horizontal_rule.accelerator=Shortcut+H | |
| 404 | App.action.insert.horizontal_rule.text=_Horizontal Rule | |
| 405 | App.action.insert.horizontal_rule.icon=LIST_OL | |
| 406 | ||
| 407 | ||
| 408 | App.action.definition.create.description=Create a new variable definition | |
| 409 | App.action.definition.create.text=_Create | |
| 410 | App.action.definition.create.icon=TREE | |
| 411 | App.action.definition.create.tooltip=Add new item (Insert) | |
| 412 | ||
| 413 | App.action.definition.rename.description=Rename the selected variable definition | |
| 414 | App.action.definition.rename.text=_Rename | |
| 415 | App.action.definition.rename.icon=EDIT | |
| 416 | App.action.definition.rename.tooltip=Rename selected item (F2) | |
| 417 | ||
| 418 | App.action.definition.delete.description=Delete the selected variable definitions | |
| 419 | App.action.definition.delete.text=_Delete | |
| 420 | App.action.definition.delete.icon=TRASH | |
| 421 | App.action.definition.delete.tooltip=Delete selected items (Delete) | |
| 422 | ||
| 423 | App.action.definition.insert.description=Insert a definition | |
| 424 | App.action.definition.insert.accelerator=Ctrl+Space | |
| 425 | App.action.definition.insert.text=_Insert | |
| 426 | App.action.definition.insert.icon=STAR | |
| 427 | ||
| 428 | ||
| 429 | App.action.view.refresh.description=Clear all caches | |
| 430 | App.action.view.refresh.accelerator=F5 | |
| 431 | App.action.view.refresh.text=Refresh | |
| 432 | ||
| 433 | App.action.view.issues.description=Open document issues | |
| 434 | App.action.view.issues.accelerator=F6 | |
| 435 | App.action.view.issues.text=Issues | |
| 436 | ||
| 437 | App.action.view.preview.description=Open document preview | |
| 438 | App.action.view.preview.accelerator=F7 | |
| 439 | App.action.view.preview.text=Preview | |
| 440 | ||
| 441 | App.action.view.menubar.description=Toggle menu bar | |
| 442 | App.action.view.menubar.accelerator=Ctrl+F7 | |
| 443 | App.action.view.menubar.text=Menu bar | |
| 444 | ||
| 445 | App.action.view.toolbar.description=Toggle tool bar | |
| 446 | App.action.view.toolbar.accelerator=Ctrl+Shift+F7 | |
| 447 | App.action.view.toolbar.text=Tool bar | |
| 448 | ||
| 449 | App.action.view.statusbar.description=Toggle status bar | |
| 450 | App.action.view.statusbar.accelerator=Ctrl+Shift+Alt+F7 | |
| 451 | App.action.view.statusbar.text=Status bar | |
| 452 | ||
| 453 | App.action.view.outline.description=Open document outline | |
| 454 | App.action.view.outline.accelerator=F8 | |
| 455 | App.action.view.outline.text=Outline | |
| 421 | 456 | |
| 422 | 457 | App.action.view.files.description=Open file system browser |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.definition; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.io; |
| 3 | 3 |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors.markdown; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.preferences.Workspace; |
| 5 | import com.keenwrite.processors.markdown.extensions.ImageLinkExtension; | |
| 5 | 6 | import com.vladsch.flexmark.html.HtmlRenderer; |
| 6 | 7 | import com.vladsch.flexmark.parser.Parser; |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.r; |
| 3 | 3 |
| 1 | 1 | /* |
| 2 | * Copyright 2020 White Magic Software, Ltd. | |
| 2 | * Copyright 2020-2021 White Magic Software, Ltd. | |
| 3 | 3 | * |
| 4 | 4 | * All rights reserved. |
| 1 | /* Copyright 2020 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 |