| 7 | 7 | Download and install the following software packages: |
| 8 | 8 | |
| 9 | * [JDK 16](https://bell-sw.com/pages/downloads/?version=java-16) (Full JDK + JavaFX) | |
| 10 | * [Gradle 7.0](https://services.gradle.org/distributions) | |
| 11 | * [Git 2.28.0](https://git-scm.com/downloads) | |
| 9 | * [JDK 17](https://bell-sw.com/pages/downloads/?version=java-17) (Full JDK + JavaFX) | |
| 10 | * [Gradle 7.2](https://gradle.org/releases) | |
| 11 | * [Git 2.33](https://git-scm.com/downloads) | |
| 12 | 12 | |
| 13 | 13 | ## Repository |
| ... | ||
| 29 | 29 | |
| 30 | 30 | # Run |
| 31 | ||
| 32 | After the application is compiled, run it as follows: | |
| 33 | ||
| 34 | java --illegal-access=permit -jar build/libs/keenwrite.jar | |
| 35 | ||
| 36 | On Windows: | |
| 37 | 31 | |
| 38 | java --illegal-access=permit -jar build\libs\keenwrite.jar | |
| 32 | After the application is compiled, run it using `keenwrite.sh`. | |
| 39 | 33 | |
| 40 | 34 | # Integrated development environments |
| ... | ||
| 58 | 52 | |
| 59 | 53 | The project is imported into the IDE. |
| 60 | ||
| 61 | ### Configure | |
| 62 | ||
| 63 | Configure the IDE to run the application as follows: | |
| 64 | ||
| 65 | 1. Click **Run → Edit Configurations**. | |
| 66 | 1. Click **+** to add a new configuration. | |
| 67 | 1. Set **Name** to: KeenWrite | |
| 68 | 1. Click **Modify Options → Add VM options**. | |
| 69 | 1. Set **VM options** field to: `--illegal-access=permit` | |
| 70 | 1. Click **OK** close the dialog. | |
| 71 | 54 | |
| 72 | The changes should resemble: | |
| 55 | ### Run | |
| 73 | 56 | |
| 74 |  | |
| 57 | Run the application within the IDE as follows: | |
| 75 | 58 | |
| 76 | ### Run | |
| 59 | 1. Open **Launcher.java**. | |
| 60 | 1. Click **Run → Launcher**. | |
| 77 | 61 | |
| 78 | Click **Run → KeenWrite** to launch the application. | |
| 62 | The application is started. | |
| 79 | 63 | |
| 80 | 64 | # Installers |
| 36 | 36 | ### Other |
| 37 | 37 | |
| 38 | Download and install a full version of [JRE 16](https://bell-sw.com/pages/downloads/?version=java-16&package=jre-full) that includes JavaFX module support, then run: | |
| 38 | On other platforms, start the application as follows: | |
| 39 | 39 | |
| 40 | ``` bash | |
| 41 | java --illegal-access=permit -jar build/libs/keenwrite.jar 2> /dev/null | |
| 42 | ``` | |
| 40 | 1. Download the *full version* of the Java Runtime Environment, [JRE 17](https://bell-sw.com/pages/downloads/?version=java-17). | |
| 41 | 1. Install the JRE. | |
| 42 | 1. Open a terminal window. | |
| 43 | 1. Verify the installation: `java -version` | |
| 44 | 1. Make `keenwrite.sh` executable. | |
| 45 | 1. Run: `./keenwrite.sh` | |
| 43 | 46 | |
| 44 | The `--illegal-access=permit` is a temporary option until third-party libraries used by the text editor are updated or replaced. | |
| 47 | The application is started. | |
| 45 | 48 | |
| 46 | 49 | ## Features |
| 7 | 7 | repositories { |
| 8 | 8 | mavenCentral() |
| 9 | jcenter() | |
| 10 | 9 | |
| 11 | 10 | maven { |
| ... | ||
| 38 | 37 | "--add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED", |
| 39 | 38 | "--add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED", |
| 40 | "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED", | |
| 41 | 39 | "--add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED", |
| 40 | "--add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED", | |
| 41 | "--add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED", | |
| 42 | "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED", | |
| 42 | 43 | ] |
| 43 | 44 | |
| 44 | 45 | javafx { |
| 45 | version = "16" | |
| 46 | version = "17" | |
| 46 | 47 | modules = ['javafx.controls', 'javafx.swing'] |
| 47 | 48 | configuration = 'compileOnly' |
| 48 | 49 | } |
| 49 | 50 | |
| 50 | 51 | dependencies { |
| 51 | def v_junit = '5.7.2' | |
| 52 | def v_junit = '5.8.1' | |
| 52 | 53 | def v_flexmark = '0.62.2' |
| 53 | def v_jackson = '2.12.5' | |
| 54 | def v_jackson = '2.13.0' | |
| 54 | 55 | def v_batik = '1.14' |
| 55 | 56 | def v_wheatsheaf = '2.0.1' |
| 56 | 57 | |
| 57 | 58 | // JavaFX |
| 58 | implementation 'org.controlsfx:controlsfx:11.1.0' | |
| 59 | implementation 'org.fxmisc.richtext:richtextfx:0.10.6' | |
| 59 | // TODO: Reinstate when JDK 17-compatible release is published | |
| 60 | //implementation 'org.controlsfx:controlsfx:11.1.0' | |
| 61 | implementation 'org.fxmisc.richtext:richtextfx:0.10.7' | |
| 62 | implementation 'org.fxmisc.flowless:flowless:0.6.7' | |
| 60 | 63 | implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3' |
| 61 | 64 | implementation 'com.miglayout:miglayout-javafx:11.0' |
| ... | ||
| 86 | 89 | |
| 87 | 90 | // HTML parsing and rendering |
| 88 | implementation 'org.jsoup:jsoup:1.14.2' | |
| 89 | implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22' | |
| 91 | implementation 'org.jsoup:jsoup:1.14.3' | |
| 92 | // TODO: Wait for https://github.com/flyingsaucerproject/flyingsaucer/pull/170 | |
| 93 | //implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22' | |
| 90 | 94 | |
| 91 | 95 | // R |
| ... | ||
| 116 | 120 | implementation 'org.greenrobot:eventbus:3.2.0' |
| 117 | 121 | |
| 118 | // TODO: Update Workspace config to use Jackson to shave ~800kb | |
| 119 | 122 | implementation 'org.apache.commons:commons-configuration2:2.7' |
| 123 | //noinspection GradlePackageUpdate | |
| 120 | 124 | implementation 'commons-beanutils:commons-beanutils:1.9.4' |
| 121 | 125 | |
| ... | ||
| 131 | 135 | } |
| 132 | 136 | |
| 137 | testImplementation "org.testfx:testfx-junit5:4.0.16-alpha" | |
| 133 | 138 | testImplementation "org.junit.jupiter:junit-jupiter-api:${v_junit}" |
| 134 | 139 | testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' |
| 135 | ||
| 136 | testImplementation "org.testfx:testfx-junit5:4.0.16-alpha" | |
| 137 | 140 | } |
| 138 | 141 | |
| 12 | 12 | readonly APP_NAME=$(find "${SCRIPT_DIR}/src" -type f -name "settings.properties" -exec cat {} \; | grep "application.title=" | cut -d'=' -f2) |
| 13 | 13 | readonly FILE_APP_JAR="${APP_NAME}.jar" |
| 14 | ||
| 15 | # JDK 16 work-around until RichTextFX is fixed. | |
| 16 | # See: https://github.com/FXMisc/RichTextFX/issues/1013 | |
| 17 | readonly OPT_JAVA="--illegal-access=permit" | |
| 14 | readonly OPT_JAVA=$(cat << END_OF_ARGS | |
| 15 | --add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \ | |
| 16 | --add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED \ | |
| 17 | --add-opens=javafx.graphics/javafx.scene.text=ALL-UNNAMED \ | |
| 18 | --add-opens=javafx.graphics/com.sun.javafx.css=ALL-UNNAMED \ | |
| 19 | --add-opens=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \ | |
| 20 | --add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED \ | |
| 21 | --add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED \ | |
| 22 | --add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED \ | |
| 23 | --add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \ | |
| 24 | --add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED \ | |
| 25 | --add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED | |
| 26 | END_OF_ARGS | |
| 27 | ) | |
| 18 | 28 | |
| 19 | 29 | ARG_JAVA_OS="linux" |
| 20 | 30 | ARG_JAVA_ARCH="amd64" |
| 21 | ARG_JAVA_VERSION="16.0.1" | |
| 22 | ARG_JAVA_UPDATE="9" | |
| 31 | ARG_JAVA_VERSION="17" | |
| 32 | ARG_JAVA_UPDATE="35" | |
| 23 | 33 | ARG_JAVA_DIR="java" |
| 24 | 34 |
| 1 | 1 | #!/usr/bin/env bash |
| 2 | 2 | |
| 3 | java --illegal-access=permit -jar build/libs/keenwrite.jar 2> /dev/null | |
| 3 | java \ | |
| 4 | --add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \ | |
| 5 | --add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED \ | |
| 6 | --add-opens=javafx.graphics/javafx.scene.text=ALL-UNNAMED \ | |
| 7 | --add-opens=javafx.graphics/com.sun.javafx.css=ALL-UNNAMED \ | |
| 8 | --add-opens=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \ | |
| 9 | --add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED \ | |
| 10 | --add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED \ | |
| 11 | --add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED \ | |
| 12 | --add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \ | |
| 13 | --add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED \ | |
| 14 | --add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED \ | |
| 15 | --add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED \ | |
| 16 | -jar build/libs/keenwrite.jar | |
| 4 | 17 | |
| 5 | 18 |
| 4 | 4 | import com.keenwrite.events.HyperlinkOpenEvent; |
| 5 | 5 | import com.keenwrite.preferences.Workspace; |
| 6 | import com.keenwrite.util.ArrayScanner; | |
| 6 | 7 | import javafx.application.Application; |
| 7 | 8 | import javafx.event.Event; |
| ... | ||
| 41 | 42 | */ |
| 42 | 43 | public static void main( final String[] args ) { |
| 43 | disableLogging(); | |
| 44 | if( !ArrayScanner.contains( args, "--debug" ) ) { | |
| 45 | disableLogging(); | |
| 46 | } | |
| 47 | ||
| 44 | 48 | launch( args ); |
| 45 | 49 | } |
| 13 | 13 | import com.keenwrite.preferences.Key; |
| 14 | 14 | import com.keenwrite.preferences.Workspace; |
| 15 | import com.keenwrite.preview.HtmlPanel; | |
| 16 | import com.keenwrite.preview.HtmlPreview; | |
| 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.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.keenwrite.ui.explorer.FilePickerFactory; | |
| 27 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 28 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 29 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 30 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 31 | import javafx.application.Platform; | |
| 32 | import javafx.beans.property.*; | |
| 33 | import javafx.collections.ListChangeListener; | |
| 34 | import javafx.concurrent.Task; | |
| 35 | import javafx.event.ActionEvent; | |
| 36 | import javafx.event.Event; | |
| 37 | import javafx.event.EventHandler; | |
| 38 | import javafx.scene.Node; | |
| 39 | import javafx.scene.Scene; | |
| 40 | import javafx.scene.control.*; | |
| 41 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 42 | import javafx.scene.input.KeyEvent; | |
| 43 | import javafx.scene.layout.FlowPane; | |
| 44 | import javafx.stage.Stage; | |
| 45 | import javafx.stage.Window; | |
| 46 | import org.greenrobot.eventbus.Subscribe; | |
| 47 | ||
| 48 | import java.io.File; | |
| 49 | import java.io.FileNotFoundException; | |
| 50 | import java.nio.file.Path; | |
| 51 | import java.util.*; | |
| 52 | import java.util.concurrent.ExecutorService; | |
| 53 | import java.util.concurrent.ScheduledExecutorService; | |
| 54 | import java.util.concurrent.ScheduledFuture; | |
| 55 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 56 | import java.util.concurrent.atomic.AtomicReference; | |
| 57 | import java.util.function.Function; | |
| 58 | import java.util.stream.Collectors; | |
| 59 | ||
| 60 | import static com.keenwrite.ExportFormat.NONE; | |
| 61 | import static com.keenwrite.Messages.get; | |
| 62 | import static com.keenwrite.constants.Constants.*; | |
| 63 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 64 | import static com.keenwrite.events.Bus.register; | |
| 65 | import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | |
| 66 | import static com.keenwrite.events.StatusEvent.clue; | |
| 67 | import static com.keenwrite.io.MediaType.*; | |
| 68 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 69 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 70 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 71 | import static java.lang.String.format; | |
| 72 | import static java.lang.System.getProperty; | |
| 73 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 74 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 75 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 76 | import static java.util.stream.Collectors.groupingBy; | |
| 77 | import static javafx.application.Platform.runLater; | |
| 78 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 79 | import static javafx.scene.control.ButtonType.*; | |
| 80 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 81 | import static javafx.scene.input.KeyCode.SPACE; | |
| 82 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 83 | import static javafx.util.Duration.millis; | |
| 84 | import static javax.swing.SwingUtilities.invokeLater; | |
| 85 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 86 | ||
| 87 | /** | |
| 88 | * Responsible for wiring together the main application components for a | |
| 89 | * particular workspace (project). These include the definition views, | |
| 90 | * text editors, and preview pane along with any corresponding controllers. | |
| 91 | */ | |
| 92 | public final class MainPane extends SplitPane { | |
| 93 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 94 | ||
| 95 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 96 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 97 | new AtomicReference<>(); | |
| 98 | ||
| 99 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 100 | ||
| 101 | /** | |
| 102 | * Used when opening files to determine how each file should be binned and | |
| 103 | * therefore what tab pane to be opened within. | |
| 104 | */ | |
| 105 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 106 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 107 | ); | |
| 108 | ||
| 109 | /** | |
| 110 | * Prevents re-instantiation of processing classes. | |
| 111 | */ | |
| 112 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 113 | new HashMap<>(); | |
| 114 | ||
| 115 | private final Workspace mWorkspace; | |
| 116 | ||
| 117 | /** | |
| 118 | * Groups similar file type tabs together. | |
| 119 | */ | |
| 120 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 121 | ||
| 122 | /** | |
| 123 | * Stores definition names and values. | |
| 124 | */ | |
| 125 | private final Map<String, String> mResolvedMap = | |
| 126 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 127 | ||
| 128 | /** | |
| 129 | * Renders the actively selected plain text editor tab. | |
| 130 | */ | |
| 131 | private final HtmlPreview mPreview; | |
| 132 | ||
| 133 | /** | |
| 134 | * Provides an interactive document outline. | |
| 135 | */ | |
| 136 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 137 | ||
| 138 | /** | |
| 139 | * Changing the active editor fires the value changed event. This allows | |
| 140 | * refreshes to happen when external definitions are modified and need to | |
| 141 | * trigger the processing chain. | |
| 142 | */ | |
| 143 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 144 | createActiveTextEditor(); | |
| 145 | ||
| 146 | /** | |
| 147 | * Changing the active definition editor fires the value changed event. This | |
| 148 | * allows refreshes to happen when external definitions are modified and need | |
| 149 | * to trigger the processing chain. | |
| 150 | */ | |
| 151 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 152 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 153 | ||
| 154 | /** | |
| 155 | * Tracks the number of detached tab panels opened into their own windows, | |
| 156 | * which allows unique identification of subordinate windows by their title. | |
| 157 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 158 | */ | |
| 159 | private byte mWindowCount; | |
| 160 | ||
| 161 | /** | |
| 162 | * Called when the definition data is changed. | |
| 163 | */ | |
| 164 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 165 | event -> { | |
| 166 | final var editor = mActiveDefinitionEditor.get(); | |
| 167 | ||
| 168 | resolve( editor ); | |
| 169 | process( getActiveTextEditor() ); | |
| 170 | save( editor ); | |
| 171 | }; | |
| 172 | ||
| 173 | private final DocumentStatistics mStatistics; | |
| 174 | ||
| 175 | /** | |
| 176 | * Adds all content panels to the main user interface. This will load the | |
| 177 | * configuration settings from the workspace to reproduce the settings from | |
| 178 | * a previous session. | |
| 179 | */ | |
| 180 | public MainPane( final Workspace workspace ) { | |
| 181 | mWorkspace = workspace; | |
| 182 | mPreview = new HtmlPreview( workspace ); | |
| 183 | mStatistics = new DocumentStatistics( workspace ); | |
| 184 | mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 185 | ||
| 186 | open( bin( getRecentFiles() ) ); | |
| 187 | viewPreview(); | |
| 188 | setDividerPositions( calculateDividerPositions() ); | |
| 189 | ||
| 190 | // Once the main scene's window regains focus, update the active definition | |
| 191 | // editor to the currently selected tab. | |
| 192 | runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 193 | // Order matters here. We want to close all the tabs to ensure each | |
| 194 | // is saved, but after they are closed, the workspace should still | |
| 195 | // retain the list of files that were open. If this line came after | |
| 196 | // closing, then restarting the application would list no files. | |
| 197 | mWorkspace.save(); | |
| 198 | ||
| 199 | if( closeAll() ) { | |
| 200 | Platform.exit(); | |
| 201 | System.exit( 0 ); | |
| 202 | } | |
| 203 | else { | |
| 204 | event.consume(); | |
| 205 | } | |
| 206 | } ) ); | |
| 207 | ||
| 208 | register( this ); | |
| 209 | initAutosave( workspace ); | |
| 210 | } | |
| 211 | ||
| 212 | @Subscribe | |
| 213 | public void handle( final TextEditorFocusEvent event ) { | |
| 214 | mActiveTextEditor.set( event.get() ); | |
| 215 | } | |
| 216 | ||
| 217 | @Subscribe | |
| 218 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 219 | mActiveDefinitionEditor.set( event.get() ); | |
| 220 | } | |
| 221 | ||
| 222 | /** | |
| 223 | * Typically called when a file name is clicked in the {@link HtmlPanel}. | |
| 224 | * | |
| 225 | * @param event The event to process, must contain a valid file reference. | |
| 226 | */ | |
| 227 | @Subscribe | |
| 228 | public void handle( final FileOpenEvent event ) { | |
| 229 | final File eventFile; | |
| 230 | final var eventUri = event.getUri(); | |
| 231 | ||
| 232 | if( eventUri.isAbsolute() ) { | |
| 233 | eventFile = new File( eventUri.getPath() ); | |
| 234 | } | |
| 235 | else { | |
| 236 | final var activeFile = getActiveTextEditor().getFile(); | |
| 237 | final var parent = activeFile.getParentFile(); | |
| 238 | ||
| 239 | if( parent == null ) { | |
| 240 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 241 | return; | |
| 242 | } | |
| 243 | else { | |
| 244 | final var parentPath = parent.getAbsolutePath(); | |
| 245 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | runLater( () -> open( eventFile ) ); | |
| 250 | } | |
| 251 | ||
| 252 | @Subscribe | |
| 253 | public void handle( final CaretNavigationEvent event ) { | |
| 254 | runLater( () -> { | |
| 255 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 256 | textArea.moveTo( event.getOffset() ); | |
| 257 | textArea.requestFollowCaret(); | |
| 258 | textArea.requestFocus(); | |
| 259 | } ); | |
| 260 | } | |
| 261 | ||
| 262 | @Subscribe | |
| 263 | @SuppressWarnings( "unused" ) | |
| 264 | public void handle( final ExportFailedEvent event ) { | |
| 265 | final var os = getProperty( "os.name" ); | |
| 266 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 267 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 268 | ||
| 269 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 270 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 271 | final var version = Messages.get( | |
| 272 | "Alert.typesetter.missing.version", | |
| 273 | os, | |
| 274 | arch | |
| 275 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 276 | .replaceAll( "mips.*", "MIPS" ) | |
| 277 | .replaceAll( "armv.*", "ARM" ), | |
| 278 | bits ); | |
| 279 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 280 | ||
| 281 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 282 | final var content = format( "%s %s", text, version ); | |
| 283 | final var flowPane = new FlowPane(); | |
| 284 | final var link = new Hyperlink( text ); | |
| 285 | final var label = new Label( version ); | |
| 286 | flowPane.getChildren().addAll( link, label ); | |
| 287 | ||
| 288 | final var alert = new Alert( ERROR, content, OK ); | |
| 289 | alert.setTitle( title ); | |
| 290 | alert.setHeaderText( header ); | |
| 291 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 292 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 293 | ||
| 294 | link.setOnAction( ( e ) -> { | |
| 295 | alert.close(); | |
| 296 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 297 | runLater( () -> fireHyperlinkOpenEvent( url ) ); | |
| 298 | } ); | |
| 299 | ||
| 300 | alert.showAndWait(); | |
| 301 | } | |
| 302 | ||
| 303 | private void initAutosave( final Workspace workspace ) { | |
| 304 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 305 | ||
| 306 | rate.addListener( | |
| 307 | ( c, o, n ) -> { | |
| 308 | final var taskRef = mSaveTask.get(); | |
| 309 | ||
| 310 | // Prevent multiple autosaves from running. | |
| 311 | if( taskRef != null ) { | |
| 312 | taskRef.cancel( false ); | |
| 313 | } | |
| 314 | ||
| 315 | initAutosave( rate ); | |
| 316 | } | |
| 317 | ); | |
| 318 | ||
| 319 | // Start the save listener (avoids duplicating some code). | |
| 320 | initAutosave( rate ); | |
| 321 | } | |
| 322 | ||
| 323 | private void initAutosave( final IntegerProperty rate ) { | |
| 324 | mSaveTask.set( | |
| 325 | mSaver.scheduleAtFixedRate( | |
| 326 | () -> { | |
| 327 | if( getActiveTextEditor().isModified() ) { | |
| 328 | // Ensure the modified indicator is cleared by running on EDT. | |
| 329 | runLater( this::save ); | |
| 330 | } | |
| 331 | }, 0, rate.intValue(), SECONDS | |
| 332 | ) | |
| 333 | ); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 338 | */ | |
| 339 | private double[] calculateDividerPositions() { | |
| 340 | final var ratio = 100f / getItems().size() / 100; | |
| 341 | final var positions = getDividerPositions(); | |
| 342 | ||
| 343 | for( int i = 0; i < positions.length; i++ ) { | |
| 344 | positions[ i ] = ratio * i; | |
| 345 | } | |
| 346 | ||
| 347 | return positions; | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * Opens all the files into the application, provided the paths are unique. | |
| 352 | * This may only be called for any type of files that a user can edit | |
| 353 | * (i.e., update and persist), such as definitions and text files. | |
| 354 | * | |
| 355 | * @param files The list of files to open. | |
| 356 | */ | |
| 357 | public void open( final List<File> files ) { | |
| 358 | files.forEach( this::open ); | |
| 359 | } | |
| 360 | ||
| 361 | /** | |
| 362 | * This opens the given file. Since the preview pane is not a file that | |
| 363 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 364 | * | |
| 365 | * @param file The file to open. | |
| 366 | */ | |
| 367 | private void open( final File file ) { | |
| 368 | final var tab = createTab( file ); | |
| 369 | final var node = tab.getContent(); | |
| 370 | final var mediaType = MediaType.valueFrom( file ); | |
| 371 | final var tabPane = obtainTabPane( mediaType ); | |
| 372 | ||
| 373 | tab.setTooltip( createTooltip( file ) ); | |
| 374 | tabPane.setFocusTraversable( false ); | |
| 375 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 376 | tabPane.getTabs().add( tab ); | |
| 377 | ||
| 378 | // Attach the tab scene factory for new tab panes. | |
| 379 | if( !getItems().contains( tabPane ) ) { | |
| 380 | addTabPane( | |
| 381 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 382 | ); | |
| 383 | } | |
| 384 | ||
| 385 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 386 | } | |
| 387 | ||
| 388 | /** | |
| 389 | * Opens a new text editor document using the default document file name. | |
| 390 | */ | |
| 391 | public void newTextEditor() { | |
| 392 | open( DOCUMENT_DEFAULT ); | |
| 393 | } | |
| 394 | ||
| 395 | /** | |
| 396 | * Opens a new definition editor document using the default definition | |
| 397 | * file name. | |
| 398 | */ | |
| 399 | public void newDefinitionEditor() { | |
| 400 | open( DEFINITION_DEFAULT ); | |
| 401 | } | |
| 402 | ||
| 403 | /** | |
| 404 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 405 | * that they save themselves. | |
| 406 | */ | |
| 407 | public void saveAll() { | |
| 408 | mTabPanes.forEach( | |
| 409 | ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 410 | final var node = tab.getContent(); | |
| 411 | if( node instanceof final TextEditor editor ) { | |
| 412 | save( editor ); | |
| 413 | } | |
| 414 | } ) | |
| 415 | ); | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 420 | * checking if modified first because if the user swaps external media from | |
| 421 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 422 | * the user: save always re-saves. Also, it's less code. | |
| 423 | */ | |
| 424 | public void save() { | |
| 425 | save( getActiveTextEditor() ); | |
| 426 | } | |
| 427 | ||
| 428 | /** | |
| 429 | * Saves the active {@link TextEditor} under a new name. | |
| 430 | * | |
| 431 | * @param files The new active editor {@link File} reference, must contain | |
| 432 | * at least one element. | |
| 433 | */ | |
| 434 | public void saveAs( final List<File> files ) { | |
| 435 | assert files != null; | |
| 436 | assert !files.isEmpty(); | |
| 437 | final var editor = getActiveTextEditor(); | |
| 438 | final var tab = getTab( editor ); | |
| 439 | final var file = files.get( 0 ); | |
| 440 | ||
| 441 | editor.rename( file ); | |
| 442 | tab.ifPresent( t -> { | |
| 443 | t.setText( editor.getFilename() ); | |
| 444 | t.setTooltip( createTooltip( file ) ); | |
| 445 | } ); | |
| 446 | ||
| 447 | save(); | |
| 448 | } | |
| 449 | ||
| 450 | /** | |
| 451 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 452 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 453 | * | |
| 454 | * @param resource The resource to export. | |
| 455 | */ | |
| 456 | private void save( final TextResource resource ) { | |
| 457 | try { | |
| 458 | resource.save(); | |
| 459 | } catch( final Exception ex ) { | |
| 460 | clue( ex ); | |
| 461 | sNotifier.alert( | |
| 462 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 463 | ); | |
| 464 | } | |
| 465 | } | |
| 466 | ||
| 467 | /** | |
| 468 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 469 | * | |
| 470 | * @return {@code true} when all editors, modified or otherwise, were | |
| 471 | * permitted to close; {@code false} when one or more editors were modified | |
| 472 | * and the user requested no closing. | |
| 473 | */ | |
| 474 | public boolean closeAll() { | |
| 475 | var closable = true; | |
| 476 | ||
| 477 | for( final var tabPane : mTabPanes ) { | |
| 478 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 479 | ||
| 480 | while( tabIterator.hasNext() ) { | |
| 481 | final var tab = tabIterator.next(); | |
| 482 | final var resource = tab.getContent(); | |
| 483 | ||
| 484 | // The definition panes auto-save, so being specific here prevents | |
| 485 | // closing the definitions in the situation where the user wants to | |
| 486 | // continue editing (i.e., possibly save unsaved work). | |
| 487 | if( !(resource instanceof TextEditor) ) { | |
| 488 | continue; | |
| 489 | } | |
| 490 | ||
| 491 | if( canClose( (TextEditor) resource ) ) { | |
| 492 | tabIterator.remove(); | |
| 493 | close( tab ); | |
| 494 | } | |
| 495 | else { | |
| 496 | closable = false; | |
| 497 | } | |
| 498 | } | |
| 499 | } | |
| 500 | ||
| 501 | return closable; | |
| 502 | } | |
| 503 | ||
| 504 | /** | |
| 505 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 506 | * event. | |
| 507 | * | |
| 508 | * @param tab The {@link Tab} that was closed. | |
| 509 | */ | |
| 510 | private void close( final Tab tab ) { | |
| 511 | assert tab != null; | |
| 512 | ||
| 513 | final var handler = tab.getOnClosed(); | |
| 514 | ||
| 515 | if( handler != null ) { | |
| 516 | handler.handle( new ActionEvent() ); | |
| 517 | } | |
| 518 | } | |
| 519 | ||
| 520 | /** | |
| 521 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 522 | */ | |
| 523 | public void close() { | |
| 524 | final var editor = getActiveTextEditor(); | |
| 525 | ||
| 526 | if( canClose( editor ) ) { | |
| 527 | close( editor ); | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | /** | |
| 532 | * Closes the given {@link TextResource}. This must not be called from within | |
| 533 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 534 | * concurrent modification exception be thrown. | |
| 535 | * | |
| 536 | * @param resource The {@link TextResource} to close, without confirming with | |
| 537 | * the user. | |
| 538 | */ | |
| 539 | private void close( final TextResource resource ) { | |
| 540 | getTab( resource ).ifPresent( | |
| 541 | ( tab ) -> { | |
| 542 | close( tab ); | |
| 543 | tab.getTabPane().getTabs().remove( tab ); | |
| 544 | } | |
| 545 | ); | |
| 546 | } | |
| 547 | ||
| 548 | /** | |
| 549 | * Answers whether the given {@link TextResource} may be closed. | |
| 550 | * | |
| 551 | * @param editor The {@link TextResource} to try closing. | |
| 552 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 553 | * the user has requested to keep the editor open. | |
| 554 | */ | |
| 555 | private boolean canClose( final TextResource editor ) { | |
| 556 | final var editorTab = getTab( editor ); | |
| 557 | final var canClose = new AtomicBoolean( true ); | |
| 558 | ||
| 559 | if( editor.isModified() ) { | |
| 560 | final var filename = new StringBuilder(); | |
| 561 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 562 | ||
| 563 | final var message = sNotifier.createNotification( | |
| 564 | Messages.get( "Alert.file.close.title" ), | |
| 565 | Messages.get( "Alert.file.close.text" ), | |
| 566 | filename.toString() | |
| 567 | ); | |
| 568 | ||
| 569 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 570 | ||
| 571 | dialog.showAndWait().ifPresent( | |
| 572 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 573 | ); | |
| 574 | } | |
| 575 | ||
| 576 | return canClose.get(); | |
| 577 | } | |
| 578 | ||
| 579 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 580 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 581 | ||
| 582 | editor.addListener( ( c, o, n ) -> { | |
| 583 | if( n != null ) { | |
| 584 | mPreview.setBaseUri( n.getPath() ); | |
| 585 | process( n ); | |
| 586 | } | |
| 587 | } ); | |
| 588 | ||
| 589 | return editor; | |
| 590 | } | |
| 591 | ||
| 592 | /** | |
| 593 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 594 | */ | |
| 595 | public void viewPreview() { | |
| 596 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 597 | } | |
| 598 | ||
| 599 | /** | |
| 600 | * Adds the document outline tab to its own, singular tab pane. | |
| 601 | */ | |
| 602 | public void viewOutline() { | |
| 603 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 604 | } | |
| 605 | ||
| 606 | public void viewStatistics() { | |
| 607 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 608 | } | |
| 609 | ||
| 610 | public void viewFiles() { | |
| 611 | try { | |
| 612 | final var factory = new FilePickerFactory( mWorkspace ); | |
| 613 | final var fileManager = factory.createModeless(); | |
| 614 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 615 | } catch( final Exception ex ) { | |
| 616 | clue( ex ); | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | private void viewTab( | |
| 621 | final Node node, final MediaType mediaType, final String key ) { | |
| 622 | final var tabPane = obtainTabPane( mediaType ); | |
| 623 | ||
| 624 | for( final var tab : tabPane.getTabs() ) { | |
| 625 | if( tab.getContent() == node ) { | |
| 626 | return; | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 631 | addTabPane( tabPane ); | |
| 632 | } | |
| 633 | ||
| 634 | public void viewRefresh() { | |
| 635 | mPreview.refresh(); | |
| 636 | } | |
| 637 | ||
| 638 | /** | |
| 639 | * Returns the tab that contains the given {@link TextEditor}. | |
| 640 | * | |
| 641 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 642 | * @return The first tab having content that matches the given tab. | |
| 643 | */ | |
| 644 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 645 | return mTabPanes.stream() | |
| 646 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 647 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 648 | .findFirst(); | |
| 649 | } | |
| 650 | ||
| 651 | /** | |
| 652 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 653 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 654 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 655 | * text editor is refreshed. | |
| 656 | * | |
| 657 | * @param editor Text editor to update with the revised resolved map. | |
| 658 | * @return A newly configured property that represents the active | |
| 659 | * {@link DefinitionEditor}, never null. | |
| 660 | */ | |
| 661 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 662 | final ObjectProperty<TextEditor> editor ) { | |
| 663 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 664 | definitions.addListener( ( c, o, n ) -> { | |
| 665 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 666 | process( editor.get() ); | |
| 667 | } ); | |
| 668 | ||
| 669 | return definitions; | |
| 670 | } | |
| 671 | ||
| 672 | private Tab createTab( final String filename, final Node node ) { | |
| 673 | return new DetachableTab( filename, node ); | |
| 674 | } | |
| 675 | ||
| 676 | private Tab createTab( final File file ) { | |
| 677 | final var r = createTextResource( file ); | |
| 678 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 679 | ||
| 680 | r.modifiedProperty().addListener( | |
| 681 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 682 | ); | |
| 683 | ||
| 684 | // This is called when either the tab is closed by the user clicking on | |
| 685 | // the tab's close icon or when closing (all) from the file menu. | |
| 686 | tab.setOnClosed( | |
| 687 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 688 | ); | |
| 15 | import com.keenwrite.preview.HtmlPreview; | |
| 16 | import com.keenwrite.processors.Processor; | |
| 17 | import com.keenwrite.processors.ProcessorContext; | |
| 18 | import com.keenwrite.processors.ProcessorFactory; | |
| 19 | import com.keenwrite.processors.markdown.extensions.CaretExtension; | |
| 20 | import com.keenwrite.service.events.Notifier; | |
| 21 | import com.keenwrite.sigils.RSigilOperator; | |
| 22 | import com.keenwrite.sigils.SigilOperator; | |
| 23 | import com.keenwrite.sigils.Tokens; | |
| 24 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 25 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 26 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 27 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 28 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 29 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 30 | import javafx.application.Platform; | |
| 31 | import javafx.beans.property.*; | |
| 32 | import javafx.collections.ListChangeListener; | |
| 33 | import javafx.concurrent.Task; | |
| 34 | import javafx.event.ActionEvent; | |
| 35 | import javafx.event.Event; | |
| 36 | import javafx.event.EventHandler; | |
| 37 | import javafx.scene.Node; | |
| 38 | import javafx.scene.Scene; | |
| 39 | import javafx.scene.control.*; | |
| 40 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 41 | import javafx.scene.input.KeyEvent; | |
| 42 | import javafx.scene.layout.FlowPane; | |
| 43 | import javafx.stage.Stage; | |
| 44 | import javafx.stage.Window; | |
| 45 | import org.greenrobot.eventbus.Subscribe; | |
| 46 | ||
| 47 | import java.io.File; | |
| 48 | import java.io.FileNotFoundException; | |
| 49 | import java.nio.file.Path; | |
| 50 | import java.util.*; | |
| 51 | import java.util.concurrent.ExecutorService; | |
| 52 | import java.util.concurrent.ScheduledExecutorService; | |
| 53 | import java.util.concurrent.ScheduledFuture; | |
| 54 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 55 | import java.util.concurrent.atomic.AtomicReference; | |
| 56 | import java.util.function.Function; | |
| 57 | import java.util.stream.Collectors; | |
| 58 | ||
| 59 | import static com.keenwrite.ExportFormat.NONE; | |
| 60 | import static com.keenwrite.Messages.get; | |
| 61 | import static com.keenwrite.constants.Constants.*; | |
| 62 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 63 | import static com.keenwrite.events.Bus.register; | |
| 64 | import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | |
| 65 | import static com.keenwrite.events.StatusEvent.clue; | |
| 66 | import static com.keenwrite.io.MediaType.*; | |
| 67 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 68 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 69 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 70 | import static java.lang.String.format; | |
| 71 | import static java.lang.System.getProperty; | |
| 72 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 73 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 74 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 75 | import static java.util.stream.Collectors.groupingBy; | |
| 76 | import static javafx.application.Platform.runLater; | |
| 77 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 78 | import static javafx.scene.control.ButtonType.*; | |
| 79 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 80 | import static javafx.scene.input.KeyCode.SPACE; | |
| 81 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 82 | import static javafx.util.Duration.millis; | |
| 83 | import static javax.swing.SwingUtilities.invokeLater; | |
| 84 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 85 | ||
| 86 | /** | |
| 87 | * Responsible for wiring together the main application components for a | |
| 88 | * particular workspace (project). These include the definition views, | |
| 89 | * text editors, and preview pane along with any corresponding controllers. | |
| 90 | */ | |
| 91 | public final class MainPane extends SplitPane { | |
| 92 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 93 | ||
| 94 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 95 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 96 | new AtomicReference<>(); | |
| 97 | ||
| 98 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 99 | ||
| 100 | /** | |
| 101 | * Used when opening files to determine how each file should be binned and | |
| 102 | * therefore what tab pane to be opened within. | |
| 103 | */ | |
| 104 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 105 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 106 | ); | |
| 107 | ||
| 108 | /** | |
| 109 | * Prevents re-instantiation of processing classes. | |
| 110 | */ | |
| 111 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 112 | new HashMap<>(); | |
| 113 | ||
| 114 | private final Workspace mWorkspace; | |
| 115 | ||
| 116 | /** | |
| 117 | * Groups similar file type tabs together. | |
| 118 | */ | |
| 119 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 120 | ||
| 121 | /** | |
| 122 | * Stores definition names and values. | |
| 123 | */ | |
| 124 | private final Map<String, String> mResolvedMap = | |
| 125 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 126 | ||
| 127 | /** | |
| 128 | * Renders the actively selected plain text editor tab. | |
| 129 | */ | |
| 130 | private final HtmlPreview mPreview; | |
| 131 | ||
| 132 | /** | |
| 133 | * Provides an interactive document outline. | |
| 134 | */ | |
| 135 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 136 | ||
| 137 | /** | |
| 138 | * Changing the active editor fires the value changed event. This allows | |
| 139 | * refreshes to happen when external definitions are modified and need to | |
| 140 | * trigger the processing chain. | |
| 141 | */ | |
| 142 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 143 | createActiveTextEditor(); | |
| 144 | ||
| 145 | /** | |
| 146 | * Changing the active definition editor fires the value changed event. This | |
| 147 | * allows refreshes to happen when external definitions are modified and need | |
| 148 | * to trigger the processing chain. | |
| 149 | */ | |
| 150 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 151 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 152 | ||
| 153 | /** | |
| 154 | * Tracks the number of detached tab panels opened into their own windows, | |
| 155 | * which allows unique identification of subordinate windows by their title. | |
| 156 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 157 | */ | |
| 158 | private byte mWindowCount; | |
| 159 | ||
| 160 | /** | |
| 161 | * Called when the definition data is changed. | |
| 162 | */ | |
| 163 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 164 | event -> { | |
| 165 | final var editor = mActiveDefinitionEditor.get(); | |
| 166 | ||
| 167 | resolve( editor ); | |
| 168 | process( getActiveTextEditor() ); | |
| 169 | save( editor ); | |
| 170 | }; | |
| 171 | ||
| 172 | private final DocumentStatistics mStatistics; | |
| 173 | ||
| 174 | /** | |
| 175 | * Adds all content panels to the main user interface. This will load the | |
| 176 | * configuration settings from the workspace to reproduce the settings from | |
| 177 | * a previous session. | |
| 178 | */ | |
| 179 | public MainPane( final Workspace workspace ) { | |
| 180 | mWorkspace = workspace; | |
| 181 | mPreview = new HtmlPreview( workspace ); | |
| 182 | mStatistics = new DocumentStatistics( workspace ); | |
| 183 | mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 184 | ||
| 185 | open( bin( getRecentFiles() ) ); | |
| 186 | viewPreview(); | |
| 187 | setDividerPositions( calculateDividerPositions() ); | |
| 188 | ||
| 189 | // Once the main scene's window regains focus, update the active definition | |
| 190 | // editor to the currently selected tab. | |
| 191 | runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 192 | // Order matters here. We want to close all the tabs to ensure each | |
| 193 | // is saved, but after they are closed, the workspace should still | |
| 194 | // retain the list of files that were open. If this line came after | |
| 195 | // closing, then restarting the application would list no files. | |
| 196 | mWorkspace.save(); | |
| 197 | ||
| 198 | if( closeAll() ) { | |
| 199 | Platform.exit(); | |
| 200 | System.exit( 0 ); | |
| 201 | } | |
| 202 | else { | |
| 203 | event.consume(); | |
| 204 | } | |
| 205 | } ) ); | |
| 206 | ||
| 207 | register( this ); | |
| 208 | initAutosave( workspace ); | |
| 209 | } | |
| 210 | ||
| 211 | @Subscribe | |
| 212 | public void handle( final TextEditorFocusEvent event ) { | |
| 213 | mActiveTextEditor.set( event.get() ); | |
| 214 | } | |
| 215 | ||
| 216 | @Subscribe | |
| 217 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 218 | mActiveDefinitionEditor.set( event.get() ); | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Typically called when a file name is clicked in the preview panel. | |
| 223 | * | |
| 224 | * @param event The event to process, must contain a valid file reference. | |
| 225 | */ | |
| 226 | @Subscribe | |
| 227 | public void handle( final FileOpenEvent event ) { | |
| 228 | final File eventFile; | |
| 229 | final var eventUri = event.getUri(); | |
| 230 | ||
| 231 | if( eventUri.isAbsolute() ) { | |
| 232 | eventFile = new File( eventUri.getPath() ); | |
| 233 | } | |
| 234 | else { | |
| 235 | final var activeFile = getActiveTextEditor().getFile(); | |
| 236 | final var parent = activeFile.getParentFile(); | |
| 237 | ||
| 238 | if( parent == null ) { | |
| 239 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 240 | return; | |
| 241 | } | |
| 242 | else { | |
| 243 | final var parentPath = parent.getAbsolutePath(); | |
| 244 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | runLater( () -> open( eventFile ) ); | |
| 249 | } | |
| 250 | ||
| 251 | @Subscribe | |
| 252 | public void handle( final CaretNavigationEvent event ) { | |
| 253 | runLater( () -> { | |
| 254 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 255 | textArea.moveTo( event.getOffset() ); | |
| 256 | textArea.requestFollowCaret(); | |
| 257 | textArea.requestFocus(); | |
| 258 | } ); | |
| 259 | } | |
| 260 | ||
| 261 | @Subscribe | |
| 262 | @SuppressWarnings( "unused" ) | |
| 263 | public void handle( final ExportFailedEvent event ) { | |
| 264 | final var os = getProperty( "os.name" ); | |
| 265 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 266 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 267 | ||
| 268 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 269 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 270 | final var version = Messages.get( | |
| 271 | "Alert.typesetter.missing.version", | |
| 272 | os, | |
| 273 | arch | |
| 274 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 275 | .replaceAll( "mips.*", "MIPS" ) | |
| 276 | .replaceAll( "armv.*", "ARM" ), | |
| 277 | bits ); | |
| 278 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 279 | ||
| 280 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 281 | final var content = format( "%s %s", text, version ); | |
| 282 | final var flowPane = new FlowPane(); | |
| 283 | final var link = new Hyperlink( text ); | |
| 284 | final var label = new Label( version ); | |
| 285 | flowPane.getChildren().addAll( link, label ); | |
| 286 | ||
| 287 | final var alert = new Alert( ERROR, content, OK ); | |
| 288 | alert.setTitle( title ); | |
| 289 | alert.setHeaderText( header ); | |
| 290 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 291 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 292 | ||
| 293 | link.setOnAction( ( e ) -> { | |
| 294 | alert.close(); | |
| 295 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 296 | runLater( () -> fireHyperlinkOpenEvent( url ) ); | |
| 297 | } ); | |
| 298 | ||
| 299 | alert.showAndWait(); | |
| 300 | } | |
| 301 | ||
| 302 | private void initAutosave( final Workspace workspace ) { | |
| 303 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 304 | ||
| 305 | rate.addListener( | |
| 306 | ( c, o, n ) -> { | |
| 307 | final var taskRef = mSaveTask.get(); | |
| 308 | ||
| 309 | // Prevent multiple autosaves from running. | |
| 310 | if( taskRef != null ) { | |
| 311 | taskRef.cancel( false ); | |
| 312 | } | |
| 313 | ||
| 314 | initAutosave( rate ); | |
| 315 | } | |
| 316 | ); | |
| 317 | ||
| 318 | // Start the save listener (avoids duplicating some code). | |
| 319 | initAutosave( rate ); | |
| 320 | } | |
| 321 | ||
| 322 | private void initAutosave( final IntegerProperty rate ) { | |
| 323 | mSaveTask.set( | |
| 324 | mSaver.scheduleAtFixedRate( | |
| 325 | () -> { | |
| 326 | if( getActiveTextEditor().isModified() ) { | |
| 327 | // Ensure the modified indicator is cleared by running on EDT. | |
| 328 | runLater( this::save ); | |
| 329 | } | |
| 330 | }, 0, rate.intValue(), SECONDS | |
| 331 | ) | |
| 332 | ); | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * TODO: Load divider positions from exported settings, see | |
| 337 | * {@link #bin(SetProperty)} comment. | |
| 338 | */ | |
| 339 | private double[] calculateDividerPositions() { | |
| 340 | final var ratio = 100f / getItems().size() / 100; | |
| 341 | final var positions = getDividerPositions(); | |
| 342 | ||
| 343 | for( int i = 0; i < positions.length; i++ ) { | |
| 344 | positions[ i ] = ratio * i; | |
| 345 | } | |
| 346 | ||
| 347 | return positions; | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * Opens all the files into the application, provided the paths are unique. | |
| 352 | * This may only be called for any type of files that a user can edit | |
| 353 | * (i.e., update and persist), such as definitions and text files. | |
| 354 | * | |
| 355 | * @param files The list of files to open. | |
| 356 | */ | |
| 357 | public void open( final List<File> files ) { | |
| 358 | files.forEach( this::open ); | |
| 359 | } | |
| 360 | ||
| 361 | /** | |
| 362 | * This opens the given file. Since the preview pane is not a file that | |
| 363 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 364 | * | |
| 365 | * @param file The file to open. | |
| 366 | */ | |
| 367 | private void open( final File file ) { | |
| 368 | final var tab = createTab( file ); | |
| 369 | final var node = tab.getContent(); | |
| 370 | final var mediaType = MediaType.valueFrom( file ); | |
| 371 | final var tabPane = obtainTabPane( mediaType ); | |
| 372 | ||
| 373 | tab.setTooltip( createTooltip( file ) ); | |
| 374 | tabPane.setFocusTraversable( false ); | |
| 375 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 376 | tabPane.getTabs().add( tab ); | |
| 377 | ||
| 378 | // Attach the tab scene factory for new tab panes. | |
| 379 | if( !getItems().contains( tabPane ) ) { | |
| 380 | addTabPane( | |
| 381 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 382 | ); | |
| 383 | } | |
| 384 | ||
| 385 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 386 | } | |
| 387 | ||
| 388 | /** | |
| 389 | * Opens a new text editor document using the default document file name. | |
| 390 | */ | |
| 391 | public void newTextEditor() { | |
| 392 | open( DOCUMENT_DEFAULT ); | |
| 393 | } | |
| 394 | ||
| 395 | /** | |
| 396 | * Opens a new definition editor document using the default definition | |
| 397 | * file name. | |
| 398 | */ | |
| 399 | public void newDefinitionEditor() { | |
| 400 | open( DEFINITION_DEFAULT ); | |
| 401 | } | |
| 402 | ||
| 403 | /** | |
| 404 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 405 | * that they save themselves. | |
| 406 | */ | |
| 407 | public void saveAll() { | |
| 408 | mTabPanes.forEach( | |
| 409 | ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 410 | final var node = tab.getContent(); | |
| 411 | if( node instanceof final TextEditor editor ) { | |
| 412 | save( editor ); | |
| 413 | } | |
| 414 | } ) | |
| 415 | ); | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 420 | * checking if modified first because if the user swaps external media from | |
| 421 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 422 | * the user: save always re-saves. Also, it's less code. | |
| 423 | */ | |
| 424 | public void save() { | |
| 425 | save( getActiveTextEditor() ); | |
| 426 | } | |
| 427 | ||
| 428 | /** | |
| 429 | * Saves the active {@link TextEditor} under a new name. | |
| 430 | * | |
| 431 | * @param files The new active editor {@link File} reference, must contain | |
| 432 | * at least one element. | |
| 433 | */ | |
| 434 | public void saveAs( final List<File> files ) { | |
| 435 | assert files != null; | |
| 436 | assert !files.isEmpty(); | |
| 437 | final var editor = getActiveTextEditor(); | |
| 438 | final var tab = getTab( editor ); | |
| 439 | final var file = files.get( 0 ); | |
| 440 | ||
| 441 | editor.rename( file ); | |
| 442 | tab.ifPresent( t -> { | |
| 443 | t.setText( editor.getFilename() ); | |
| 444 | t.setTooltip( createTooltip( file ) ); | |
| 445 | } ); | |
| 446 | ||
| 447 | save(); | |
| 448 | } | |
| 449 | ||
| 450 | /** | |
| 451 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 452 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 453 | * | |
| 454 | * @param resource The resource to export. | |
| 455 | */ | |
| 456 | private void save( final TextResource resource ) { | |
| 457 | try { | |
| 458 | resource.save(); | |
| 459 | } catch( final Exception ex ) { | |
| 460 | clue( ex ); | |
| 461 | sNotifier.alert( | |
| 462 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 463 | ); | |
| 464 | } | |
| 465 | } | |
| 466 | ||
| 467 | /** | |
| 468 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 469 | * | |
| 470 | * @return {@code true} when all editors, modified or otherwise, were | |
| 471 | * permitted to close; {@code false} when one or more editors were modified | |
| 472 | * and the user requested no closing. | |
| 473 | */ | |
| 474 | public boolean closeAll() { | |
| 475 | var closable = true; | |
| 476 | ||
| 477 | for( final var tabPane : mTabPanes ) { | |
| 478 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 479 | ||
| 480 | while( tabIterator.hasNext() ) { | |
| 481 | final var tab = tabIterator.next(); | |
| 482 | final var resource = tab.getContent(); | |
| 483 | ||
| 484 | // The definition panes auto-save, so being specific here prevents | |
| 485 | // closing the definitions in the situation where the user wants to | |
| 486 | // continue editing (i.e., possibly save unsaved work). | |
| 487 | if( !(resource instanceof TextEditor) ) { | |
| 488 | continue; | |
| 489 | } | |
| 490 | ||
| 491 | if( canClose( (TextEditor) resource ) ) { | |
| 492 | tabIterator.remove(); | |
| 493 | close( tab ); | |
| 494 | } | |
| 495 | else { | |
| 496 | closable = false; | |
| 497 | } | |
| 498 | } | |
| 499 | } | |
| 500 | ||
| 501 | return closable; | |
| 502 | } | |
| 503 | ||
| 504 | /** | |
| 505 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 506 | * event. | |
| 507 | * | |
| 508 | * @param tab The {@link Tab} that was closed. | |
| 509 | */ | |
| 510 | private void close( final Tab tab ) { | |
| 511 | assert tab != null; | |
| 512 | ||
| 513 | final var handler = tab.getOnClosed(); | |
| 514 | ||
| 515 | if( handler != null ) { | |
| 516 | handler.handle( new ActionEvent() ); | |
| 517 | } | |
| 518 | } | |
| 519 | ||
| 520 | /** | |
| 521 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 522 | */ | |
| 523 | public void close() { | |
| 524 | final var editor = getActiveTextEditor(); | |
| 525 | ||
| 526 | if( canClose( editor ) ) { | |
| 527 | close( editor ); | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | /** | |
| 532 | * Closes the given {@link TextResource}. This must not be called from within | |
| 533 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 534 | * concurrent modification exception be thrown. | |
| 535 | * | |
| 536 | * @param resource The {@link TextResource} to close, without confirming with | |
| 537 | * the user. | |
| 538 | */ | |
| 539 | private void close( final TextResource resource ) { | |
| 540 | getTab( resource ).ifPresent( | |
| 541 | ( tab ) -> { | |
| 542 | close( tab ); | |
| 543 | tab.getTabPane().getTabs().remove( tab ); | |
| 544 | } | |
| 545 | ); | |
| 546 | } | |
| 547 | ||
| 548 | /** | |
| 549 | * Answers whether the given {@link TextResource} may be closed. | |
| 550 | * | |
| 551 | * @param editor The {@link TextResource} to try closing. | |
| 552 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 553 | * the user has requested to keep the editor open. | |
| 554 | */ | |
| 555 | private boolean canClose( final TextResource editor ) { | |
| 556 | final var editorTab = getTab( editor ); | |
| 557 | final var canClose = new AtomicBoolean( true ); | |
| 558 | ||
| 559 | if( editor.isModified() ) { | |
| 560 | final var filename = new StringBuilder(); | |
| 561 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 562 | ||
| 563 | final var message = sNotifier.createNotification( | |
| 564 | Messages.get( "Alert.file.close.title" ), | |
| 565 | Messages.get( "Alert.file.close.text" ), | |
| 566 | filename.toString() | |
| 567 | ); | |
| 568 | ||
| 569 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 570 | ||
| 571 | dialog.showAndWait().ifPresent( | |
| 572 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 573 | ); | |
| 574 | } | |
| 575 | ||
| 576 | return canClose.get(); | |
| 577 | } | |
| 578 | ||
| 579 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 580 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 581 | ||
| 582 | editor.addListener( ( c, o, n ) -> { | |
| 583 | if( n != null ) { | |
| 584 | mPreview.setBaseUri( n.getPath() ); | |
| 585 | process( n ); | |
| 586 | } | |
| 587 | } ); | |
| 588 | ||
| 589 | return editor; | |
| 590 | } | |
| 591 | ||
| 592 | /** | |
| 593 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 594 | */ | |
| 595 | public void viewPreview() { | |
| 596 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 597 | } | |
| 598 | ||
| 599 | /** | |
| 600 | * Adds the document outline tab to its own, singular tab pane. | |
| 601 | */ | |
| 602 | public void viewOutline() { | |
| 603 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 604 | } | |
| 605 | ||
| 606 | public void viewStatistics() { | |
| 607 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 608 | } | |
| 609 | ||
| 610 | public void viewFiles() { | |
| 611 | try { | |
| 612 | final var factory = new FilePickerFactory( mWorkspace ); | |
| 613 | final var fileManager = factory.createModeless(); | |
| 614 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 615 | } catch( final Exception ex ) { | |
| 616 | clue( ex ); | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | private void viewTab( | |
| 621 | final Node node, final MediaType mediaType, final String key ) { | |
| 622 | final var tabPane = obtainTabPane( mediaType ); | |
| 623 | ||
| 624 | for( final var tab : tabPane.getTabs() ) { | |
| 625 | if( tab.getContent() == node ) { | |
| 626 | return; | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 631 | addTabPane( tabPane ); | |
| 632 | } | |
| 633 | ||
| 634 | public void viewRefresh() { | |
| 635 | mPreview.refresh(); | |
| 636 | } | |
| 637 | ||
| 638 | /** | |
| 639 | * Returns the tab that contains the given {@link TextEditor}. | |
| 640 | * | |
| 641 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 642 | * @return The first tab having content that matches the given tab. | |
| 643 | */ | |
| 644 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 645 | return mTabPanes.stream() | |
| 646 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 647 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 648 | .findFirst(); | |
| 649 | } | |
| 650 | ||
| 651 | /** | |
| 652 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 653 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 654 | * Upon changing, the {@link #mResolvedMap} is updated and the active | |
| 655 | * text editor is refreshed. | |
| 656 | * | |
| 657 | * @param editor Text editor to update with the revised resolved map. | |
| 658 | * @return A newly configured property that represents the active | |
| 659 | * {@link DefinitionEditor}, never null. | |
| 660 | */ | |
| 661 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 662 | final ObjectProperty<TextEditor> editor ) { | |
| 663 | final var definitions = new SimpleObjectProperty<TextDefinition>(); | |
| 664 | definitions.addListener( ( c, o, n ) -> { | |
| 665 | resolve( n == null ? createDefinitionEditor() : n ); | |
| 666 | process( editor.get() ); | |
| 667 | } ); | |
| 668 | ||
| 669 | return definitions; | |
| 670 | } | |
| 671 | ||
| 672 | private Tab createTab( final String filename, final Node node ) { | |
| 673 | return new DetachableTab( filename, node ); | |
| 674 | } | |
| 675 | ||
| 676 | private Tab createTab( final File file ) { | |
| 677 | final var r = createTextResource( file ); | |
| 678 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 679 | ||
| 680 | r.modifiedProperty().addListener( | |
| 681 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 682 | ); | |
| 683 | ||
| 684 | // This is called when either the tab is closed by the user clicking on | |
| 685 | // the tab's close icon or when closing (all) from the file menu. | |
| 686 | tab.setOnClosed( | |
| 687 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 688 | ); | |
| 689 | ||
| 690 | // When closing a tab, give focus to the newly revealed tab. | |
| 691 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 692 | if( n != null && n ) { | |
| 693 | final var pane = tab.getTabPane(); | |
| 694 | ||
| 695 | if( pane != null ) { | |
| 696 | pane.requestFocus(); | |
| 697 | } | |
| 698 | } | |
| 699 | } ); | |
| 689 | 700 | |
| 690 | 701 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { |
| 3 | 3 | |
| 4 | 4 | import com.fasterxml.jackson.databind.JsonNode; |
| 5 | import com.keenwrite.preview.HtmlPreview; | |
| 6 | 5 | import javafx.scene.control.TreeItem; |
| 7 | 6 | import javafx.scene.control.TreeView; |
| ... | ||
| 27 | 26 | * <p> |
| 28 | 27 | * This class is responsible for producing the interpolated flat map. This |
| 29 | * allows dynamic edits of the {@link TreeView} to be displayed in the | |
| 30 | * {@link HtmlPreview} without having to reload the definition file. | |
| 31 | * Reloading the definition file would work, but has a number of drawbacks. | |
| 28 | * allows dynamic edits of the {@link TreeView} to be displayed without | |
| 29 | * having to reload the definition file. Reloading the definition file would | |
| 30 | * work, but has a number of drawbacks. | |
| 32 | 31 | * </p> |
| 33 | 32 | */ |
| 2 | 2 | package com.keenwrite.events; |
| 3 | 3 | |
| 4 | import org.jsoup.nodes.Document; | |
| 5 | ||
| 6 | 4 | /** |
| 7 | 5 | * Collates information about an HTML document that has changed. |
| ... | ||
| 34 | 32 | * @param html The document that may have changed. |
| 35 | 33 | */ |
| 36 | public static void fireDocumentChangedEvent( final Document html ) { | |
| 34 | public static void fireDocumentChangedEvent( final String html ) { | |
| 37 | 35 | // Hashing the document text ignores caret position changes. |
| 38 | final var text = html.wholeText(); | |
| 39 | final var hash = text.hashCode(); | |
| 36 | final var hash = html.hashCode(); | |
| 40 | 37 | |
| 41 | 38 | if( hash != sHash ) { |
| 42 | 39 | sHash = hash; |
| 43 | new DocumentChangedEvent( text ).fire(); | |
| 40 | new DocumentChangedEvent( html ).fire(); | |
| 44 | 41 | } |
| 45 | 42 | } |
| 2 | 2 | package com.keenwrite.events; |
| 3 | 3 | |
| 4 | import com.keenwrite.preview.HtmlPanel; | |
| 5 | ||
| 6 | 4 | import java.net.URI; |
| 7 | 5 | |
| 8 | 6 | /** |
| 9 | 7 | * Collates information about a file requested to be opened. This can be called |
| 10 | * when the user clicks a hyperlink in the {@link HtmlPanel}. | |
| 8 | * when the user clicks a hyperlink in HTML preview panel. | |
| 11 | 9 | */ |
| 12 | 10 | public class FileOpenEvent implements AppEvent { |
| 61 | 61 | * <dd>Fully qualified file name, which includes all parent directories.</dd> |
| 62 | 62 | * <dt>Dir</dt> |
| 63 | * <dd>Directory without a file name ({@link File#isDirectory()} is true) | |
| 64 | * .</dd> | |
| 63 | * <dd>Directory without file name ({@link File#isDirectory()} is true).</dd> | |
| 65 | 64 | * </dl> |
| 66 | 65 | */ |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preview; | |
| 3 | ||
| 4 | import com.keenwrite.ui.adapters.DocumentAdapter; | |
| 5 | import javafx.beans.property.BooleanProperty; | |
| 6 | import javafx.beans.property.SimpleBooleanProperty; | |
| 7 | import org.w3c.dom.Document; | |
| 8 | import org.xhtmlrenderer.layout.SharedContext; | |
| 9 | import org.xhtmlrenderer.render.Box; | |
| 10 | import org.xhtmlrenderer.simple.XHTMLPanel; | |
| 11 | import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler; | |
| 12 | import org.xhtmlrenderer.swing.*; | |
| 13 | ||
| 14 | import javax.swing.*; | |
| 15 | import java.awt.*; | |
| 16 | import java.awt.event.ComponentAdapter; | |
| 17 | import java.awt.event.ComponentEvent; | |
| 18 | import java.net.URI; | |
| 19 | ||
| 20 | import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent; | |
| 21 | import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | |
| 22 | import static com.keenwrite.events.StatusEvent.clue; | |
| 23 | import static com.keenwrite.util.ProtocolScheme.getProtocol; | |
| 24 | import static java.lang.Boolean.FALSE; | |
| 25 | import static java.lang.Boolean.TRUE; | |
| 26 | import static java.lang.Math.max; | |
| 27 | import static java.lang.Thread.sleep; | |
| 28 | import static javax.swing.SwingUtilities.invokeLater; | |
| 29 | ||
| 30 | /** | |
| 31 | * Responsible for configuring FlyingSaucer's {@link XHTMLPanel}. | |
| 32 | */ | |
| 33 | public final class FlyingSaucerPanel extends XHTMLPanel implements | |
| 34 | HtmlRenderer { | |
| 35 | ||
| 36 | /** | |
| 37 | * Suppresses scroll attempts until after the document has loaded. | |
| 38 | */ | |
| 39 | private static final class DocumentEventHandler extends DocumentAdapter { | |
| 40 | private final BooleanProperty mReadyProperty = new SimpleBooleanProperty(); | |
| 41 | ||
| 42 | @Override | |
| 43 | public void documentStarted() { | |
| 44 | mReadyProperty.setValue( FALSE ); | |
| 45 | } | |
| 46 | ||
| 47 | @Override | |
| 48 | public void documentLoaded() { | |
| 49 | mReadyProperty.setValue( TRUE ); | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Ensures that the preview panel fills its container's area completely. | |
| 55 | */ | |
| 56 | private final class ComponentEventHandler extends ComponentAdapter { | |
| 57 | /** | |
| 58 | * Invoked when the component's size changes. | |
| 59 | */ | |
| 60 | public void componentResized( final ComponentEvent e ) { | |
| 61 | setPreferredSize( e.getComponent().getPreferredSize() ); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Responsible for opening hyperlinks. External hyperlinks are opened in | |
| 67 | * the system's default browser; local file system links are opened in the | |
| 68 | * editor. | |
| 69 | */ | |
| 70 | private static final class HyperlinkListener extends LinkListener { | |
| 71 | @Override | |
| 72 | public void linkClicked( final BasicPanel panel, final String link ) { | |
| 73 | try { | |
| 74 | final var uri = new URI( link ); | |
| 75 | ||
| 76 | switch( getProtocol( uri ) ) { | |
| 77 | case HTTP -> fireHyperlinkOpenEvent( uri ); | |
| 78 | case FILE -> fireFileOpenEvent( uri ); | |
| 79 | } | |
| 80 | } catch( final Exception ex ) { | |
| 81 | clue( ex ); | |
| 82 | } | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | private static final XhtmlNamespaceHandler XNH = new XhtmlNamespaceHandler(); | |
| 87 | private final ChainedReplacedElementFactory mFactory; | |
| 88 | ||
| 89 | FlyingSaucerPanel() { | |
| 90 | // The order is important: SwingReplacedElementFactory replaces SVG images | |
| 91 | // with a blank image, which will cause the chained factory to cache the | |
| 92 | // image and exit. Instead, the SVG must execute first to rasterize the | |
| 93 | // content. Consequently, the chained factory must maintain insertion order. | |
| 94 | mFactory = new ChainedReplacedElementFactory( | |
| 95 | new SvgReplacedElementFactory(), | |
| 96 | new SwingReplacedElementFactory() | |
| 97 | ); | |
| 98 | ||
| 99 | final var context = getSharedContext(); | |
| 100 | final var textRenderer = context.getTextRenderer(); | |
| 101 | context.setReplacedElementFactory( mFactory ); | |
| 102 | textRenderer.setSmoothingThreshold( 0 ); | |
| 103 | ||
| 104 | addDocumentListener( new DocumentEventHandler() ); | |
| 105 | removeMouseTrackingListeners(); | |
| 106 | addMouseTrackingListener( new HyperlinkListener() ); | |
| 107 | addComponentListener( new ComponentEventHandler() ); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Updates the document model displayed by the renderer. Effectively, this | |
| 112 | * updates the HTML document to provide new content. | |
| 113 | * | |
| 114 | * @param doc A complete HTML5 document, including doctype. | |
| 115 | * @param baseUri URI to use for finding relative files, such as images. | |
| 116 | */ | |
| 117 | @Override | |
| 118 | public void render( final Document doc, final String baseUri ) { | |
| 119 | setDocument( doc, baseUri, XNH ); | |
| 120 | } | |
| 121 | ||
| 122 | @Override | |
| 123 | public void clearCache() { | |
| 124 | mFactory.clearCache(); | |
| 125 | } | |
| 126 | ||
| 127 | @Override | |
| 128 | public void scrollTo(final String id, final JScrollPane scrollPane) { | |
| 129 | int iter = 0; | |
| 130 | Box box = null; | |
| 131 | ||
| 132 | while( iter++ < 3 && ((box = getBoxById( id )) == null) ) { | |
| 133 | try { | |
| 134 | sleep( 10 ); | |
| 135 | } catch( final Exception ex ) { | |
| 136 | clue( ex ); | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | scrollTo( box, scrollPane ); | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Scrolls to the location specified by the {@link Box} that corresponds | |
| 145 | * to a point somewhere in the preview pane. If there is no caret, then | |
| 146 | * this will not change the scroll position. Changing the scroll position | |
| 147 | * to the top if the {@link Box} instance is {@code null} will result in | |
| 148 | * jumping around a lot and inconsistent synchronization issues. | |
| 149 | * | |
| 150 | * @param box The rectangular region containing the caret, or {@code null} | |
| 151 | * if the HTML does not have a caret. | |
| 152 | */ | |
| 153 | private void scrollTo( final Box box, final JScrollPane scrollPane ) { | |
| 154 | if( box != null ) { | |
| 155 | invokeLater( () -> { | |
| 156 | scrollTo( createPoint( box, scrollPane ) ); | |
| 157 | scrollPane.repaint(); | |
| 158 | } ); | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | /** | |
| 163 | * Creates a {@link Point} to use as a reference for scrolling to the area | |
| 164 | * described by the given {@link Box}. The {@link Box} coordinates are used | |
| 165 | * to populate the {@link Point}'s location, with minor adjustments for | |
| 166 | * vertical centering. | |
| 167 | * | |
| 168 | * @param box The {@link Box} that represents a scrolling anchor reference. | |
| 169 | * @return A coordinate suitable for scrolling to. | |
| 170 | */ | |
| 171 | private Point createPoint( final Box box, final JScrollPane scrollPane ) { | |
| 172 | assert box != null; | |
| 173 | ||
| 174 | // Scroll back up by half the height of the scroll bar to keep the typing | |
| 175 | // area within the view port. Otherwise the view port will have jumped too | |
| 176 | // high up and the most recently typed letters won't be visible. | |
| 177 | int y = max( box.getAbsY() - scrollPane.getVerticalScrollBar().getHeight() / 2, 0 ); | |
| 178 | int x = box.getAbsX(); | |
| 179 | ||
| 180 | if( !box.getStyle().isInline() ) { | |
| 181 | final var margin = box.getMargin( getLayoutContext() ); | |
| 182 | y += margin.top(); | |
| 183 | x += margin.left(); | |
| 184 | } | |
| 185 | ||
| 186 | return new Point( x, y ); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Delegates to the {@link SharedContext}. | |
| 191 | * | |
| 192 | * @param id The HTML element identifier to retrieve in {@link Box} form. | |
| 193 | * @return The {@link Box} that corresponds to the given element ID, or | |
| 194 | * {@code null} if none found. | |
| 195 | */ | |
| 196 | Box getBoxById( final String id ) { | |
| 197 | return getSharedContext().getBoxById( id ); | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * Suppress scrolling to the top on updates. | |
| 202 | */ | |
| 203 | @Override | |
| 204 | public void resetScrollPosition() { | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * The default mouse click listener attempts navigation within the preview | |
| 209 | * panel. We want to usurp that behaviour to open the link in a | |
| 210 | * platform-specific browser. | |
| 211 | */ | |
| 212 | private void removeMouseTrackingListeners() { | |
| 213 | for( final var listener : getMouseTrackingListeners() ) { | |
| 214 | if( !(listener instanceof HoverListener) ) { | |
| 215 | removeMouseTrackingListener( (FSMouseListener) listener ); | |
| 216 | } | |
| 217 | } | |
| 218 | } | |
| 219 | } | |
| 1 | 220 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preview; | |
| 3 | ||
| 4 | import com.keenwrite.dom.DocumentConverter; | |
| 5 | import com.keenwrite.ui.adapters.DocumentAdapter; | |
| 6 | import javafx.beans.property.BooleanProperty; | |
| 7 | import javafx.beans.property.SimpleBooleanProperty; | |
| 8 | import org.xhtmlrenderer.layout.SharedContext; | |
| 9 | import org.xhtmlrenderer.render.Box; | |
| 10 | import org.xhtmlrenderer.simple.XHTMLPanel; | |
| 11 | import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler; | |
| 12 | import org.xhtmlrenderer.swing.BasicPanel; | |
| 13 | import org.xhtmlrenderer.swing.FSMouseListener; | |
| 14 | import org.xhtmlrenderer.swing.HoverListener; | |
| 15 | import org.xhtmlrenderer.swing.LinkListener; | |
| 16 | ||
| 17 | import java.awt.event.ComponentAdapter; | |
| 18 | import java.awt.event.ComponentEvent; | |
| 19 | import java.net.URI; | |
| 20 | ||
| 21 | import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent; | |
| 22 | import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent; | |
| 23 | import static com.keenwrite.events.HyperlinkOpenEvent.fireHyperlinkOpenEvent; | |
| 24 | import static com.keenwrite.events.StatusEvent.clue; | |
| 25 | import static com.keenwrite.util.ProtocolScheme.getProtocol; | |
| 26 | import static java.lang.Boolean.FALSE; | |
| 27 | import static java.lang.Boolean.TRUE; | |
| 28 | import static javax.swing.SwingUtilities.invokeLater; | |
| 29 | import static javax.swing.SwingUtilities.isEventDispatchThread; | |
| 30 | import static org.jsoup.Jsoup.parse; | |
| 31 | ||
| 32 | /** | |
| 33 | * Responsible for configuring FlyingSaucer's {@link XHTMLPanel}. | |
| 34 | */ | |
| 35 | public final class HtmlPanel extends XHTMLPanel { | |
| 36 | ||
| 37 | /** | |
| 38 | * Suppresses scroll attempts until after the document has loaded. | |
| 39 | */ | |
| 40 | private static final class DocumentEventHandler extends DocumentAdapter { | |
| 41 | private final BooleanProperty mReadyProperty = new SimpleBooleanProperty(); | |
| 42 | ||
| 43 | @Override | |
| 44 | public void documentStarted() { | |
| 45 | mReadyProperty.setValue( FALSE ); | |
| 46 | } | |
| 47 | ||
| 48 | @Override | |
| 49 | public void documentLoaded() { | |
| 50 | mReadyProperty.setValue( TRUE ); | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Ensures that the preview panel fills its container's area completely. | |
| 56 | */ | |
| 57 | private final class ComponentEventHandler extends ComponentAdapter { | |
| 58 | /** | |
| 59 | * Invoked when the component's size changes. | |
| 60 | */ | |
| 61 | public void componentResized( final ComponentEvent e ) { | |
| 62 | setPreferredSize( e.getComponent().getPreferredSize() ); | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Responsible for opening hyperlinks. External hyperlinks are opened in | |
| 68 | * the system's default browser; local file system links are opened in the | |
| 69 | * editor. | |
| 70 | */ | |
| 71 | private static final class HyperlinkListener extends LinkListener { | |
| 72 | @Override | |
| 73 | public void linkClicked( final BasicPanel panel, final String link ) { | |
| 74 | try { | |
| 75 | final var uri = new URI( link ); | |
| 76 | ||
| 77 | switch( getProtocol( uri ) ) { | |
| 78 | case HTTP -> fireHyperlinkOpenEvent( uri ); | |
| 79 | case FILE -> fireFileOpenEvent( uri ); | |
| 80 | } | |
| 81 | } catch( final Exception ex ) { | |
| 82 | clue( ex ); | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | private static final DocumentConverter CONVERTER = new DocumentConverter(); | |
| 88 | private static final XhtmlNamespaceHandler XNH = new XhtmlNamespaceHandler(); | |
| 89 | ||
| 90 | public HtmlPanel() { | |
| 91 | addDocumentListener( new DocumentEventHandler() ); | |
| 92 | removeMouseTrackingListeners(); | |
| 93 | addMouseTrackingListener( new HyperlinkListener() ); | |
| 94 | addComponentListener( new ComponentEventHandler() ); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Updates the document model displayed by the renderer. Effectively, this | |
| 99 | * updates the HTML document to provide new content. | |
| 100 | * | |
| 101 | * @param html A complete HTML5 document, including doctype. | |
| 102 | * @param baseUri URI to use for finding relative files, such as images. | |
| 103 | */ | |
| 104 | public void render( final String html, final String baseUri ) { | |
| 105 | final var soup = parse( html ); | |
| 106 | final var doc = CONVERTER.fromJsoup( soup ); | |
| 107 | final Runnable renderDocument = () -> setDocument( doc, baseUri, XNH ); | |
| 108 | doc.setDocumentURI( baseUri ); | |
| 109 | ||
| 110 | // Access to a Swing component must occur from the Event Dispatch | |
| 111 | // Thread (EDT) according to Swing threading restrictions. Setting a new | |
| 112 | // document invokes a Swing repaint operation. | |
| 113 | if( isEventDispatchThread() ) { | |
| 114 | renderDocument.run(); | |
| 115 | } | |
| 116 | else { | |
| 117 | invokeLater( renderDocument ); | |
| 118 | } | |
| 119 | ||
| 120 | // When the text changes, let subscribers know. This allows for text | |
| 121 | // analysis to occur on a separate thread. | |
| 122 | fireDocumentChangedEvent( soup ); | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Delegates to the {@link SharedContext}. | |
| 127 | * | |
| 128 | * @param id The HTML element identifier to retrieve in {@link Box} form. | |
| 129 | * @return The {@link Box} that corresponds to the given element ID, or | |
| 130 | * {@code null} if none found. | |
| 131 | */ | |
| 132 | public Box getBoxById( final String id ) { | |
| 133 | return getSharedContext().getBoxById( id ); | |
| 134 | } | |
| 135 | ||
| 136 | /** | |
| 137 | * Suppress scrolling to the top on updates. | |
| 138 | */ | |
| 139 | @Override | |
| 140 | public void resetScrollPosition() { | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * The default mouse click listener attempts navigation within the preview | |
| 145 | * panel. We want to usurp that behaviour to open the link in a | |
| 146 | * platform-specific browser. | |
| 147 | */ | |
| 148 | private void removeMouseTrackingListeners() { | |
| 149 | for( final var listener : getMouseTrackingListeners() ) { | |
| 150 | if( !(listener instanceof HoverListener) ) { | |
| 151 | removeMouseTrackingListener( (FSMouseListener) listener ); | |
| 152 | } | |
| 153 | } | |
| 154 | } | |
| 155 | } | |
| 156 | 1 |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 4 | import com.keenwrite.events.ScrollLockEvent; | |
| 5 | import com.keenwrite.preferences.LocaleProperty; | |
| 6 | import com.keenwrite.preferences.Workspace; | |
| 7 | import javafx.beans.property.DoubleProperty; | |
| 8 | import javafx.beans.property.StringProperty; | |
| 9 | import javafx.embed.swing.SwingNode; | |
| 10 | import org.greenrobot.eventbus.Subscribe; | |
| 11 | import org.xhtmlrenderer.render.Box; | |
| 12 | import org.xhtmlrenderer.swing.SwingReplacedElementFactory; | |
| 13 | ||
| 14 | import javax.swing.*; | |
| 15 | import java.awt.*; | |
| 16 | import java.awt.event.ComponentEvent; | |
| 17 | import java.awt.event.ComponentListener; | |
| 18 | import java.net.URL; | |
| 19 | import java.nio.file.Path; | |
| 20 | import java.util.Locale; | |
| 21 | ||
| 22 | import static com.keenwrite.Messages.get; | |
| 23 | import static com.keenwrite.constants.Constants.*; | |
| 24 | import static com.keenwrite.events.Bus.register; | |
| 25 | import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent; | |
| 26 | import static com.keenwrite.events.StatusEvent.clue; | |
| 27 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 28 | import static com.keenwrite.ui.fonts.IconFactory.getIconFont; | |
| 29 | import static java.awt.BorderLayout.*; | |
| 30 | import static java.awt.event.KeyEvent.*; | |
| 31 | import static java.lang.Math.max; | |
| 32 | import static java.lang.String.format; | |
| 33 | import static java.lang.Thread.sleep; | |
| 34 | import static javafx.scene.CacheHint.SPEED; | |
| 35 | import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW; | |
| 36 | import static javax.swing.KeyStroke.getKeyStroke; | |
| 37 | import static javax.swing.SwingUtilities.invokeLater; | |
| 38 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK; | |
| 39 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT; | |
| 40 | ||
| 41 | /** | |
| 42 | * Responsible for parsing an HTML document. | |
| 43 | */ | |
| 44 | public final class HtmlPreview extends SwingNode implements ComponentListener { | |
| 45 | /** | |
| 46 | * Used to populate the {@link #HTML_HEAD} with stylesheet file references. | |
| 47 | */ | |
| 48 | private static final String HTML_STYLESHEET = | |
| 49 | "<link rel='stylesheet' href='%s'/>"; | |
| 50 | ||
| 51 | private static final String HTML_BASE = | |
| 52 | "<base href='%s'/>"; | |
| 53 | ||
| 54 | /** | |
| 55 | * Render CSS using points (pt) not pixels (px) to reduce the chance of | |
| 56 | * poor rendering. The {@link #generateHead()} method fills placeholders. | |
| 57 | * When the user has not set a locale, only one stylesheet is added to | |
| 58 | * the document. In order, the placeholders are as follows: | |
| 59 | * <ol> | |
| 60 | * <li>%s --- language</li> | |
| 61 | * <li>%s --- default stylesheet</li> | |
| 62 | * <li>%s --- language-specific stylesheet</li> | |
| 63 | * <li>%s --- user-customized stylesheet</li> | |
| 64 | * <li>%s --- font family</li> | |
| 65 | * <li>%d --- font size (must be pixels, not points due to bug)</li> | |
| 66 | * <li>%s --- base href</li> | |
| 67 | * </p> | |
| 68 | */ | |
| 69 | private static final String HTML_HEAD = | |
| 70 | """ | |
| 71 | <!doctype html> | |
| 72 | <html lang='%s'><head><title> </title><meta charset='utf-8'/> | |
| 73 | %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body> | |
| 74 | """; | |
| 75 | ||
| 76 | private static final String HTML_TAIL = "</body></html>"; | |
| 77 | ||
| 78 | private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW ); | |
| 79 | ||
| 80 | private final ChainedReplacedElementFactory mFactory; | |
| 81 | ||
| 82 | /** | |
| 83 | * Reusing this buffer prevents repetitious memory re-allocations. | |
| 84 | */ | |
| 85 | private final StringBuilder mDocument = new StringBuilder( 65536 ); | |
| 86 | ||
| 87 | private HtmlPanel mView; | |
| 88 | private JScrollPane mScrollPane; | |
| 89 | private String mBaseUriPath = ""; | |
| 90 | private String mHead = ""; | |
| 91 | ||
| 92 | private volatile boolean mLocked; | |
| 93 | private final JButton mScrollLockButton = new JButton(); | |
| 94 | private final Workspace mWorkspace; | |
| 95 | ||
| 96 | /** | |
| 97 | * Creates a new preview pane that can scroll to the caret position within the | |
| 98 | * document. | |
| 99 | * | |
| 100 | * @param workspace Contains locale and font size information. | |
| 101 | */ | |
| 102 | public HtmlPreview( final Workspace workspace ) { | |
| 103 | mWorkspace = workspace; | |
| 104 | ||
| 105 | // The order is important: SwingReplacedElementFactory replaces SVG images | |
| 106 | // with a blank image, which will cause the chained factory to cache the | |
| 107 | // image and exit. Instead, the SVG must execute first to rasterize the | |
| 108 | // content. Consequently, the chained factory must maintain insertion order. | |
| 109 | mFactory = new ChainedReplacedElementFactory( | |
| 110 | new SvgReplacedElementFactory(), | |
| 111 | new SwingReplacedElementFactory() | |
| 112 | ); | |
| 113 | ||
| 114 | // Attempts to prevent a flash of black un-styled content upon load. | |
| 115 | setStyle( "-fx-background-color: white;" ); | |
| 116 | ||
| 117 | invokeLater( () -> { | |
| 118 | mHead = generateHead(); | |
| 119 | mView = new HtmlPanel(); | |
| 120 | mScrollPane = new JScrollPane( mView ); | |
| 121 | final var verticalBar = mScrollPane.getVerticalScrollBar(); | |
| 122 | final var verticalPanel = new JPanel( new BorderLayout() ); | |
| 123 | ||
| 124 | final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW ); | |
| 125 | addKeyboardEvents( map ); | |
| 126 | ||
| 127 | mScrollLockButton.setFont( getIconFont( 14 ) ); | |
| 128 | mScrollLockButton.setText( getLockText( mLocked ) ); | |
| 129 | mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) ); | |
| 130 | mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) ); | |
| 131 | ||
| 132 | verticalPanel.add( verticalBar, CENTER ); | |
| 133 | verticalPanel.add( mScrollLockButton, PAGE_END ); | |
| 134 | ||
| 135 | final var wrapper = new JPanel( new BorderLayout() ); | |
| 136 | wrapper.add( mScrollPane, CENTER ); | |
| 137 | wrapper.add( verticalPanel, LINE_END ); | |
| 138 | ||
| 139 | // Enabling the cache attempts to prevent black flashes when resizing. | |
| 140 | setCache( true ); | |
| 141 | setCacheHint( SPEED ); | |
| 142 | setContent( wrapper ); | |
| 143 | wrapper.addComponentListener( this ); | |
| 144 | ||
| 145 | final var context = mView.getSharedContext(); | |
| 146 | final var textRenderer = context.getTextRenderer(); | |
| 147 | context.setReplacedElementFactory( mFactory ); | |
| 148 | textRenderer.setSmoothingThreshold( 0 ); | |
| 149 | ||
| 150 | localeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 151 | fontFamilyProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 152 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 153 | } ); | |
| 154 | ||
| 155 | register( this ); | |
| 156 | } | |
| 157 | ||
| 158 | @Subscribe | |
| 159 | public void handle( final ScrollLockEvent event ) { | |
| 160 | mLocked = event.isLocked(); | |
| 161 | invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) ); | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Updates the internal HTML source shown in the preview pane. | |
| 166 | * | |
| 167 | * @param html The new HTML document to display. | |
| 168 | */ | |
| 169 | public void render( final String html ) { | |
| 170 | mView.render( decorate( html ), getBaseUri() ); | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * Clears the caches then re-renders the content. | |
| 175 | */ | |
| 176 | public void refresh() { | |
| 177 | mFactory.clearCache(); | |
| 178 | rerender(); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Recomputes the HTML head then renders the document. | |
| 183 | */ | |
| 184 | private void rerender() { | |
| 185 | mHead = generateHead(); | |
| 186 | render( mDocument.toString() ); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Attaches the HTML head prefix and HTML tail suffix to the given HTML | |
| 191 | * string. | |
| 192 | * | |
| 193 | * @param html The HTML to adorn with opening and closing tags. | |
| 194 | * @return A complete HTML document, ready for rendering. | |
| 195 | */ | |
| 196 | private String decorate( final String html ) { | |
| 197 | mDocument.setLength( 0 ); | |
| 198 | mDocument.append( html ); | |
| 199 | ||
| 200 | // Head and tail must be separate from document due to re-rendering. | |
| 201 | return mHead + mDocument + HTML_TAIL; | |
| 202 | } | |
| 203 | ||
| 204 | /** | |
| 205 | * Called when settings are changed that affect the HTML document preamble. | |
| 206 | * This is a minor performance optimization to avoid generating the head | |
| 207 | * each time that the document itself changes. | |
| 208 | * | |
| 209 | * @return A new doctype and HTML {@code head} element. | |
| 210 | */ | |
| 211 | private String generateHead() { | |
| 212 | final var locale = getLocale(); | |
| 213 | final var base = getBaseUri(); | |
| 214 | final var custom = getCustomStylesheetUrl(); | |
| 215 | ||
| 216 | // Point sizes are converted to pixels because of a rendering bug. | |
| 217 | return format( | |
| 218 | HTML_HEAD, | |
| 219 | locale.getLanguage(), | |
| 220 | toStylesheetString( HTML_STYLE_PREVIEW ), | |
| 221 | toStylesheetString( toUrl( locale ) ), | |
| 222 | toStylesheetString( custom ), | |
| 223 | getFontFamily(), | |
| 224 | toPixels( getFontSize() ), | |
| 225 | base.isBlank() ? "" : format( HTML_BASE, base ) | |
| 226 | ); | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * Clears the preview pane by rendering an empty string. | |
| 231 | */ | |
| 232 | public void clear() { | |
| 233 | render( "" ); | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * Sets the base URI to the containing directory the file being edited. | |
| 238 | * | |
| 239 | * @param path The path to the file being edited. | |
| 240 | */ | |
| 241 | public void setBaseUri( final Path path ) { | |
| 242 | final var parent = path.getParent(); | |
| 243 | mBaseUriPath = parent == null ? "" : parent.toUri().toString(); | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Scrolls to the closest element matching the given identifier without | |
| 248 | * waiting for the document to be ready. | |
| 249 | * | |
| 250 | * @param id Scroll the preview pane to this unique paragraph identifier. | |
| 251 | */ | |
| 252 | public void scrollTo( final String id ) { | |
| 253 | if( mLocked ) { | |
| 254 | return; | |
| 255 | } | |
| 256 | ||
| 257 | invokeLater( () -> { | |
| 258 | int iter = 0; | |
| 259 | Box box = null; | |
| 260 | ||
| 261 | while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) { | |
| 262 | try { | |
| 263 | sleep( 10 ); | |
| 264 | } catch( final Exception ex ) { | |
| 265 | clue( ex ); | |
| 266 | } | |
| 267 | } | |
| 268 | ||
| 269 | scrollTo( box ); | |
| 270 | } ); | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * Scrolls to the location specified by the {@link Box} that corresponds | |
| 275 | * to a point somewhere in the preview pane. If there is no caret, then | |
| 276 | * this will not change the scroll position. Changing the scroll position | |
| 277 | * to the top if the {@link Box} instance is {@code null} will result in | |
| 278 | * jumping around a lot and inconsistent synchronization issues. | |
| 279 | * | |
| 280 | * @param box The rectangular region containing the caret, or {@code null} | |
| 281 | * if the HTML does not have a caret. | |
| 282 | */ | |
| 283 | private void scrollTo( final Box box ) { | |
| 284 | if( box != null ) { | |
| 285 | invokeLater( () -> { | |
| 286 | mView.scrollTo( createPoint( box ) ); | |
| 287 | getScrollPane().repaint(); | |
| 288 | } ); | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Creates a {@link Point} to use as a reference for scrolling to the area | |
| 294 | * described by the given {@link Box}. The {@link Box} coordinates are used | |
| 295 | * to populate the {@link Point}'s location, with minor adjustments for | |
| 296 | * vertical centering. | |
| 297 | * | |
| 298 | * @param box The {@link Box} that represents a scrolling anchor reference. | |
| 299 | * @return A coordinate suitable for scrolling to. | |
| 300 | */ | |
| 301 | private Point createPoint( final Box box ) { | |
| 302 | assert box != null; | |
| 303 | ||
| 304 | // Scroll back up by half the height of the scroll bar to keep the typing | |
| 305 | // area within the view port. Otherwise the view port will have jumped too | |
| 306 | // high up and the most recently typed letters won't be visible. | |
| 307 | int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 ); | |
| 308 | int x = box.getAbsX(); | |
| 309 | ||
| 310 | if( !box.getStyle().isInline() ) { | |
| 311 | final var margin = box.getMargin( mView.getLayoutContext() ); | |
| 312 | y += margin.top(); | |
| 313 | x += margin.left(); | |
| 314 | } | |
| 315 | ||
| 316 | return new Point( x, y ); | |
| 317 | } | |
| 318 | ||
| 319 | private String getBaseUri() { | |
| 320 | return mBaseUriPath; | |
| 321 | } | |
| 322 | ||
| 323 | private JScrollPane getScrollPane() { | |
| 324 | return mScrollPane; | |
| 325 | } | |
| 326 | ||
| 327 | public JScrollBar getVerticalScrollBar() { | |
| 328 | return getScrollPane().getVerticalScrollBar(); | |
| 329 | } | |
| 330 | ||
| 331 | private int getVerticalScrollBarHeight() { | |
| 332 | return getVerticalScrollBar().getHeight(); | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen | |
| 337 | * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166 | |
| 338 | * alpha-2 country code or UN M.49 numeric-3 area code. For example, this | |
| 339 | * could return "en-Latn-CA" for Canadian English written in the Latin | |
| 340 | * character set. | |
| 341 | * | |
| 342 | * @return Unique identifier for language and country. | |
| 343 | */ | |
| 344 | private static URL toUrl( final Locale locale ) { | |
| 345 | return toUrl( | |
| 346 | get( | |
| 347 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), | |
| 348 | locale.getLanguage(), | |
| 349 | locale.getScript(), | |
| 350 | locale.getCountry() | |
| 351 | ) | |
| 352 | ); | |
| 353 | } | |
| 354 | ||
| 355 | private static URL toUrl( final String path ) { | |
| 356 | return HtmlPreview.class.getResource( path ); | |
| 357 | } | |
| 358 | ||
| 359 | private Locale getLocale() { | |
| 360 | return localeProperty().toLocale(); | |
| 361 | } | |
| 362 | ||
| 363 | private LocaleProperty localeProperty() { | |
| 364 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 365 | } | |
| 366 | ||
| 367 | private String getFontFamily() { | |
| 368 | return fontFamilyProperty().get(); | |
| 369 | } | |
| 370 | ||
| 371 | private StringProperty fontFamilyProperty() { | |
| 372 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 373 | } | |
| 374 | ||
| 375 | private double getFontSize() { | |
| 376 | return fontSizeProperty().get(); | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * Returns the font size in points. | |
| 381 | * | |
| 382 | * @return The user-defined font size (in pt). | |
| 383 | */ | |
| 384 | private DoubleProperty fontSizeProperty() { | |
| 385 | return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ); | |
| 386 | } | |
| 387 | ||
| 388 | private String getLockText( final boolean locked ) { | |
| 389 | return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() ); | |
| 390 | } | |
| 391 | ||
| 392 | private URL getCustomStylesheetUrl() { | |
| 393 | try { | |
| 394 | return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL(); | |
| 395 | } catch( final Exception ex ) { | |
| 396 | clue( ex ); | |
| 397 | return null; | |
| 398 | } | |
| 399 | } | |
| 400 | ||
| 401 | /** | |
| 402 | * Maps keyboard events to scrollbar commands so that users may control | |
| 403 | * the {@link HtmlPreview} panel using the keyboard. | |
| 404 | * | |
| 405 | * @param map The map to update with keyboard events. | |
| 406 | */ | |
| 407 | private void addKeyboardEvents( final InputMap map ) { | |
| 408 | map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" ); | |
| 409 | map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" ); | |
| 410 | map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" ); | |
| 411 | map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" ); | |
| 412 | map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" ); | |
| 413 | map.put( getKeyStroke( VK_END, 0 ), "maxScroll" ); | |
| 414 | } | |
| 415 | ||
| 416 | @Override | |
| 417 | public void componentResized( final ComponentEvent e ) { | |
| 418 | if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) { | |
| 419 | mFactory.clearCache(); | |
| 420 | } | |
| 421 | ||
| 422 | // Force update on the Swing EDT, otherwise the scrollbar and content | |
| 423 | // will not be updated correctly on some platforms. | |
| 424 | invokeLater( () -> getContent().repaint() ); | |
| 425 | } | |
| 426 | ||
| 427 | @Override | |
| 428 | public void componentMoved( final ComponentEvent e ) { } | |
| 429 | ||
| 430 | @Override | |
| 431 | public void componentShown( final ComponentEvent e ) { } | |
| 432 | ||
| 433 | @Override | |
| 434 | public void componentHidden( final ComponentEvent e ) { } | |
| 4 | import com.keenwrite.dom.DocumentConverter; | |
| 5 | import com.keenwrite.events.ScrollLockEvent; | |
| 6 | import com.keenwrite.preferences.LocaleProperty; | |
| 7 | import com.keenwrite.preferences.Workspace; | |
| 8 | import javafx.beans.property.DoubleProperty; | |
| 9 | import javafx.beans.property.StringProperty; | |
| 10 | import javafx.embed.swing.SwingNode; | |
| 11 | import org.greenrobot.eventbus.Subscribe; | |
| 12 | ||
| 13 | import javax.swing.*; | |
| 14 | import java.awt.*; | |
| 15 | import java.awt.event.ComponentEvent; | |
| 16 | import java.awt.event.ComponentListener; | |
| 17 | import java.net.URL; | |
| 18 | import java.nio.file.Path; | |
| 19 | import java.util.Locale; | |
| 20 | ||
| 21 | import static com.keenwrite.Messages.get; | |
| 22 | import static com.keenwrite.constants.Constants.*; | |
| 23 | import static com.keenwrite.events.Bus.register; | |
| 24 | import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent; | |
| 25 | import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent; | |
| 26 | import static com.keenwrite.events.StatusEvent.clue; | |
| 27 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 28 | import static com.keenwrite.ui.fonts.IconFactory.getIconFont; | |
| 29 | import static java.awt.BorderLayout.*; | |
| 30 | import static java.awt.event.KeyEvent.*; | |
| 31 | import static java.lang.String.format; | |
| 32 | import static javafx.scene.CacheHint.SPEED; | |
| 33 | import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW; | |
| 34 | import static javax.swing.KeyStroke.getKeyStroke; | |
| 35 | import static javax.swing.SwingUtilities.invokeLater; | |
| 36 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK; | |
| 37 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT; | |
| 38 | import static org.jsoup.Jsoup.parse; | |
| 39 | ||
| 40 | /** | |
| 41 | * Responsible for parsing an HTML document. | |
| 42 | */ | |
| 43 | public final class HtmlPreview extends SwingNode implements ComponentListener { | |
| 44 | /** | |
| 45 | * Converts a text string to a structured HTML document. | |
| 46 | */ | |
| 47 | private static final DocumentConverter CONVERTER = new DocumentConverter(); | |
| 48 | ||
| 49 | /** | |
| 50 | * Used to populate the {@link #HTML_HEAD} with stylesheet file references. | |
| 51 | */ | |
| 52 | private static final String HTML_STYLESHEET = | |
| 53 | "<link rel='stylesheet' href='%s'/>"; | |
| 54 | ||
| 55 | private static final String HTML_BASE = | |
| 56 | "<base href='%s'/>"; | |
| 57 | ||
| 58 | /** | |
| 59 | * Render CSS using points (pt) not pixels (px) to reduce the chance of | |
| 60 | * poor rendering. The {@link #generateHead()} method fills placeholders. | |
| 61 | * When the user has not set a locale, only one stylesheet is added to | |
| 62 | * the document. In order, the placeholders are as follows: | |
| 63 | * <ol> | |
| 64 | * <li>%s --- language</li> | |
| 65 | * <li>%s --- default stylesheet</li> | |
| 66 | * <li>%s --- language-specific stylesheet</li> | |
| 67 | * <li>%s --- user-customized stylesheet</li> | |
| 68 | * <li>%s --- font family</li> | |
| 69 | * <li>%d --- font size (must be pixels, not points due to bug)</li> | |
| 70 | * <li>%s --- base href</li> | |
| 71 | * </p> | |
| 72 | */ | |
| 73 | private static final String HTML_HEAD = | |
| 74 | """ | |
| 75 | <!doctype html> | |
| 76 | <html lang='%s'><head><title> </title><meta charset='utf-8'/> | |
| 77 | %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body> | |
| 78 | """; | |
| 79 | ||
| 80 | private static final String HTML_TAIL = "</body></html>"; | |
| 81 | ||
| 82 | private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW ); | |
| 83 | ||
| 84 | /** | |
| 85 | * Reusing this buffer prevents repetitious memory re-allocations. | |
| 86 | */ | |
| 87 | private final StringBuilder mDocument = new StringBuilder( 65536 ); | |
| 88 | ||
| 89 | private HtmlRenderer mPreview; | |
| 90 | private JScrollPane mScrollPane; | |
| 91 | private String mBaseUriPath = ""; | |
| 92 | private String mHead; | |
| 93 | ||
| 94 | private volatile boolean mLocked; | |
| 95 | private final JButton mScrollLockButton = new JButton(); | |
| 96 | private final Workspace mWorkspace; | |
| 97 | ||
| 98 | /** | |
| 99 | * Creates a new preview pane that can scroll to the caret position within the | |
| 100 | * document. | |
| 101 | * | |
| 102 | * @param workspace Contains locale and font size information. | |
| 103 | */ | |
| 104 | public HtmlPreview( final Workspace workspace ) { | |
| 105 | mWorkspace = workspace; | |
| 106 | mHead = generateHead(); | |
| 107 | ||
| 108 | // Attempts to prevent a flash of black un-styled content upon load. | |
| 109 | setStyle( "-fx-background-color: white;" ); | |
| 110 | ||
| 111 | invokeLater( () -> { | |
| 112 | mPreview = new FlyingSaucerPanel(); | |
| 113 | mScrollPane = new JScrollPane( (Component) mPreview ); | |
| 114 | final var verticalBar = mScrollPane.getVerticalScrollBar(); | |
| 115 | final var verticalPanel = new JPanel( new BorderLayout() ); | |
| 116 | ||
| 117 | final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW ); | |
| 118 | addKeyboardEvents( map ); | |
| 119 | ||
| 120 | mScrollLockButton.setFont( getIconFont( 14 ) ); | |
| 121 | mScrollLockButton.setText( getLockText( mLocked ) ); | |
| 122 | mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) ); | |
| 123 | mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) ); | |
| 124 | ||
| 125 | verticalPanel.add( verticalBar, CENTER ); | |
| 126 | verticalPanel.add( mScrollLockButton, PAGE_END ); | |
| 127 | ||
| 128 | final var wrapper = new JPanel( new BorderLayout() ); | |
| 129 | wrapper.add( mScrollPane, CENTER ); | |
| 130 | wrapper.add( verticalPanel, LINE_END ); | |
| 131 | ||
| 132 | // Enabling the cache attempts to prevent black flashes when resizing. | |
| 133 | setCache( true ); | |
| 134 | setCacheHint( SPEED ); | |
| 135 | setContent( wrapper ); | |
| 136 | wrapper.addComponentListener( this ); | |
| 137 | } ); | |
| 138 | ||
| 139 | localeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 140 | fontFamilyProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 141 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 142 | ||
| 143 | register( this ); | |
| 144 | } | |
| 145 | ||
| 146 | @Subscribe | |
| 147 | public void handle( final ScrollLockEvent event ) { | |
| 148 | mLocked = event.isLocked(); | |
| 149 | invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) ); | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Updates the internal HTML source shown in the preview pane. | |
| 154 | * | |
| 155 | * @param html The new HTML document to display. | |
| 156 | */ | |
| 157 | public void render( final String html ) { | |
| 158 | final var doc = CONVERTER.fromJsoup( parse( decorate( html ) ) ); | |
| 159 | final var uri = getBaseUri(); | |
| 160 | doc.setDocumentURI( uri ); | |
| 161 | ||
| 162 | invokeLater( () -> mPreview.render( doc, uri ) ); | |
| 163 | ||
| 164 | fireDocumentChangedEvent( html ); | |
| 165 | } | |
| 166 | ||
| 167 | /** | |
| 168 | * Clears the caches then re-renders the content. | |
| 169 | */ | |
| 170 | public void refresh() { | |
| 171 | mPreview.clearCache(); | |
| 172 | rerender(); | |
| 173 | } | |
| 174 | ||
| 175 | /** | |
| 176 | * Recomputes the HTML head then renders the document. | |
| 177 | */ | |
| 178 | private void rerender() { | |
| 179 | mHead = generateHead(); | |
| 180 | render( mDocument.toString() ); | |
| 181 | } | |
| 182 | ||
| 183 | /** | |
| 184 | * Attaches the HTML head prefix and HTML tail suffix to the given HTML | |
| 185 | * string. | |
| 186 | * | |
| 187 | * @param html The HTML to adorn with opening and closing tags. | |
| 188 | * @return A complete HTML document, ready for rendering. | |
| 189 | */ | |
| 190 | private String decorate( final String html ) { | |
| 191 | mDocument.setLength( 0 ); | |
| 192 | mDocument.append( html ); | |
| 193 | ||
| 194 | // Head and tail must be separate from document due to re-rendering. | |
| 195 | return mHead + mDocument + HTML_TAIL; | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Called when settings are changed that affect the HTML document preamble. | |
| 200 | * This is a minor performance optimization to avoid generating the head | |
| 201 | * each time that the document itself changes. | |
| 202 | * | |
| 203 | * @return A new doctype and HTML {@code head} element. | |
| 204 | */ | |
| 205 | private String generateHead() { | |
| 206 | final var locale = getLocale(); | |
| 207 | final var base = getBaseUri(); | |
| 208 | final var custom = getCustomStylesheetUrl(); | |
| 209 | ||
| 210 | // Point sizes are converted to pixels because of a rendering bug. | |
| 211 | return format( | |
| 212 | HTML_HEAD, | |
| 213 | locale.getLanguage(), | |
| 214 | toStylesheetString( HTML_STYLE_PREVIEW ), | |
| 215 | toStylesheetString( toUrl( locale ) ), | |
| 216 | toStylesheetString( custom ), | |
| 217 | getFontFamily(), | |
| 218 | toPixels( getFontSize() ), | |
| 219 | base.isBlank() ? "" : format( HTML_BASE, base ) | |
| 220 | ); | |
| 221 | } | |
| 222 | ||
| 223 | /** | |
| 224 | * Clears the preview pane by rendering an empty string. | |
| 225 | */ | |
| 226 | public void clear() { | |
| 227 | render( "" ); | |
| 228 | } | |
| 229 | ||
| 230 | /** | |
| 231 | * Sets the base URI to the containing directory the file being edited. | |
| 232 | * | |
| 233 | * @param path The path to the file being edited. | |
| 234 | */ | |
| 235 | public void setBaseUri( final Path path ) { | |
| 236 | final var parent = path.getParent(); | |
| 237 | mBaseUriPath = parent == null ? "" : parent.toUri().toString(); | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Scrolls to the closest element matching the given identifier without | |
| 242 | * waiting for the document to be ready. | |
| 243 | * | |
| 244 | * @param id Scroll the preview pane to this unique paragraph identifier. | |
| 245 | */ | |
| 246 | public void scrollTo( final String id ) { | |
| 247 | if( !mLocked ) { | |
| 248 | invokeLater( () -> { | |
| 249 | mPreview.scrollTo( id, mScrollPane ); | |
| 250 | mScrollPane.repaint(); | |
| 251 | } ); | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 255 | private String getBaseUri() { | |
| 256 | return mBaseUriPath; | |
| 257 | } | |
| 258 | ||
| 259 | private JScrollPane getScrollPane() { | |
| 260 | return mScrollPane; | |
| 261 | } | |
| 262 | ||
| 263 | public JScrollBar getVerticalScrollBar() { | |
| 264 | return getScrollPane().getVerticalScrollBar(); | |
| 265 | } | |
| 266 | ||
| 267 | /** | |
| 268 | * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen | |
| 269 | * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166 | |
| 270 | * alpha-2 country code or UN M.49 numeric-3 area code. For example, this | |
| 271 | * could return "en-Latn-CA" for Canadian English written in the Latin | |
| 272 | * character set. | |
| 273 | * | |
| 274 | * @return Unique identifier for language and country. | |
| 275 | */ | |
| 276 | private static URL toUrl( final Locale locale ) { | |
| 277 | return toUrl( | |
| 278 | get( | |
| 279 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), | |
| 280 | locale.getLanguage(), | |
| 281 | locale.getScript(), | |
| 282 | locale.getCountry() | |
| 283 | ) | |
| 284 | ); | |
| 285 | } | |
| 286 | ||
| 287 | private static URL toUrl( final String path ) { | |
| 288 | return HtmlPreview.class.getResource( path ); | |
| 289 | } | |
| 290 | ||
| 291 | private Locale getLocale() { | |
| 292 | return localeProperty().toLocale(); | |
| 293 | } | |
| 294 | ||
| 295 | private LocaleProperty localeProperty() { | |
| 296 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 297 | } | |
| 298 | ||
| 299 | private String getFontFamily() { | |
| 300 | return fontFamilyProperty().get(); | |
| 301 | } | |
| 302 | ||
| 303 | private StringProperty fontFamilyProperty() { | |
| 304 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 305 | } | |
| 306 | ||
| 307 | private double getFontSize() { | |
| 308 | return fontSizeProperty().get(); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Returns the font size in points. | |
| 313 | * | |
| 314 | * @return The user-defined font size (in pt). | |
| 315 | */ | |
| 316 | private DoubleProperty fontSizeProperty() { | |
| 317 | return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ); | |
| 318 | } | |
| 319 | ||
| 320 | private String getLockText( final boolean locked ) { | |
| 321 | return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() ); | |
| 322 | } | |
| 323 | ||
| 324 | private URL getCustomStylesheetUrl() { | |
| 325 | try { | |
| 326 | return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL(); | |
| 327 | } catch( final Exception ex ) { | |
| 328 | clue( ex ); | |
| 329 | return null; | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * Maps keyboard events to scrollbar commands so that users may control | |
| 335 | * the {@link HtmlPreview} panel using the keyboard. | |
| 336 | * | |
| 337 | * @param map The map to update with keyboard events. | |
| 338 | */ | |
| 339 | private void addKeyboardEvents( final InputMap map ) { | |
| 340 | map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" ); | |
| 341 | map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" ); | |
| 342 | map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" ); | |
| 343 | map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" ); | |
| 344 | map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" ); | |
| 345 | map.put( getKeyStroke( VK_END, 0 ), "maxScroll" ); | |
| 346 | } | |
| 347 | ||
| 348 | @Override | |
| 349 | public void componentResized( final ComponentEvent e ) { | |
| 350 | if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) { | |
| 351 | mPreview.clearCache(); | |
| 352 | } | |
| 353 | ||
| 354 | // Force update on the Swing EDT, otherwise the scrollbar and content | |
| 355 | // will not be updated correctly on some platforms. | |
| 356 | invokeLater( () -> getContent().repaint() ); | |
| 357 | } | |
| 358 | ||
| 359 | @Override | |
| 360 | public void componentMoved( final ComponentEvent e ) {} | |
| 361 | ||
| 362 | @Override | |
| 363 | public void componentShown( final ComponentEvent e ) {} | |
| 364 | ||
| 365 | @Override | |
| 366 | public void componentHidden( final ComponentEvent e ) {} | |
| 435 | 367 | |
| 436 | 368 | private static String toStylesheetString( final URL url ) { |
| 1 | package com.keenwrite.preview; | |
| 2 | ||
| 3 | import org.w3c.dom.Document; | |
| 4 | ||
| 5 | import javax.swing.*; | |
| 6 | ||
| 7 | /** | |
| 8 | * Denotes the ability to render an HTML document onto a Swing component. | |
| 9 | */ | |
| 10 | public interface HtmlRenderer { | |
| 11 | ||
| 12 | /** | |
| 13 | * Renders an HTML document with respect to a base location. | |
| 14 | * | |
| 15 | * @param doc The document to render. | |
| 16 | * @param baseUri The document's relative URI. | |
| 17 | */ | |
| 18 | void render( final Document doc, final String baseUri ); | |
| 19 | ||
| 20 | /** | |
| 21 | * Scrolls the given {@link JScrollPane} to the first HTML element that | |
| 22 | * has an {@code id} attribute that matches the given identifier. | |
| 23 | * | |
| 24 | * @param id The HTML element identifier. | |
| 25 | * @param scrollPane The GUI widget that controls scrolling. | |
| 26 | */ | |
| 27 | void scrollTo( final String id, final JScrollPane scrollPane ); | |
| 28 | ||
| 29 | /** | |
| 30 | * Clears the cache (e.g., so that images are re-rendered using updated | |
| 31 | * dimensions). | |
| 32 | */ | |
| 33 | void clearCache(); | |
| 34 | } | |
| 1 | 35 |
| 3 | 3 | |
| 4 | 4 | import org.apache.batik.anim.dom.SAXSVGDocumentFactory; |
| 5 | import org.apache.batik.css.parser.Parser; | |
| 6 | import org.apache.batik.gvt.renderer.ImageRenderer; | |
| 7 | import org.apache.batik.transcoder.TranscoderException; | |
| 8 | import org.apache.batik.transcoder.TranscoderInput; | |
| 9 | import org.apache.batik.transcoder.TranscoderOutput; | |
| 10 | import org.apache.batik.transcoder.image.ImageTranscoder; | |
| 11 | import org.apache.batik.util.XMLResourceDescriptor; | |
| 12 | import org.w3c.css.sac.CSSException; | |
| 13 | import org.w3c.dom.Document; | |
| 14 | import org.w3c.dom.Element; | |
| 15 | ||
| 16 | import java.awt.*; | |
| 17 | import java.awt.image.BufferedImage; | |
| 18 | import java.io.File; | |
| 19 | import java.io.IOException; | |
| 20 | import java.io.InputStream; | |
| 21 | import java.io.StringReader; | |
| 22 | import java.net.URI; | |
| 23 | import java.nio.file.Path; | |
| 24 | import java.text.NumberFormat; | |
| 25 | import java.text.ParseException; | |
| 26 | ||
| 27 | import static com.keenwrite.dom.DocumentParser.transform; | |
| 28 | import static com.keenwrite.events.StatusEvent.clue; | |
| 29 | import static com.keenwrite.preview.HighQualityRenderingHints.RENDERING_HINTS; | |
| 30 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; | |
| 31 | import static java.text.NumberFormat.getIntegerInstance; | |
| 32 | import static org.apache.batik.transcoder.SVGAbstractTranscoder.KEY_WIDTH; | |
| 33 | import static org.apache.batik.transcoder.image.ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER; | |
| 34 | import static org.apache.batik.util.XMLResourceDescriptor.getXMLParserClassName; | |
| 35 | ||
| 36 | /** | |
| 37 | * Responsible for converting SVG images into rasterized PNG images. | |
| 38 | */ | |
| 39 | public final class SvgRasterizer { | |
| 40 | /** | |
| 41 | * <a href="https://issues.apache.org/jira/browse/BATIK-1112">Bug fix</a> | |
| 42 | */ | |
| 43 | public static final class InkscapeCssParser extends Parser { | |
| 44 | public void parseStyleDeclaration( final String source ) | |
| 45 | throws CSSException, IOException { | |
| 46 | super.parseStyleDeclaration( | |
| 47 | source.replaceAll( "-inkscape-font-specification:[^;\"]*;", "" ) | |
| 48 | ); | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | static { | |
| 53 | XMLResourceDescriptor.setCSSParserClassName( | |
| 54 | InkscapeCssParser.class.getName() | |
| 55 | ); | |
| 56 | } | |
| 57 | ||
| 58 | private static final SAXSVGDocumentFactory FACTORY_DOM = | |
| 59 | new SAXSVGDocumentFactory( getXMLParserClassName() ); | |
| 60 | ||
| 61 | private static final NumberFormat INT_FORMAT = getIntegerInstance(); | |
| 62 | ||
| 63 | public static final BufferedImage BROKEN_IMAGE_PLACEHOLDER; | |
| 64 | ||
| 65 | /** | |
| 66 | * A FontAwesome camera icon, cleft asunder. | |
| 67 | */ | |
| 68 | public static final String BROKEN_IMAGE_SVG = | |
| 69 | "<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www" + | |
| 70 | ".w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c" + | |
| 71 | ".332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path " + | |
| 72 | "d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531" + | |
| 73 | ".03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2" + | |
| 74 | ".511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-" + | |
| 75 | ".367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1" + | |
| 76 | ".699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 " + | |
| 77 | "2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094" + | |
| 78 | ".0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5" + | |
| 79 | ".0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-" + | |
| 80 | ".195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4" + | |
| 81 | ".191406-3.652344.207031-.015625.414063-.015625.617187-.007812l" + | |
| 82 | ".933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2" + | |
| 83 | ".820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3" + | |
| 84 | ".429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3" + | |
| 85 | ".429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 " + | |
| 86 | ".140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281" + | |
| 87 | ".808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2" + | |
| 88 | ".472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4" + | |
| 89 | ".285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1" + | |
| 90 | ".9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-" + | |
| 91 | ".667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1" + | |
| 92 | ".253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 " + | |
| 93 | "0'/></g></svg>"; | |
| 94 | ||
| 95 | static { | |
| 96 | // The width and height cannot be embedded in the SVG above because the | |
| 97 | // path element values are relative to the viewBox dimensions. | |
| 98 | final int w = 75; | |
| 99 | final int h = 75; | |
| 100 | BufferedImage image; | |
| 101 | ||
| 102 | try { | |
| 103 | image = rasterizeString( BROKEN_IMAGE_SVG, w ); | |
| 104 | } catch( final Exception ex ) { | |
| 105 | image = new BufferedImage( w, h, TYPE_INT_RGB ); | |
| 106 | final var graphics = (Graphics2D) image.getGraphics(); | |
| 107 | graphics.setRenderingHints( RENDERING_HINTS ); | |
| 108 | ||
| 109 | // Fall back to a (\) symbol. | |
| 110 | graphics.setColor( new Color( 204, 204, 204 ) ); | |
| 111 | graphics.fillRect( 0, 0, w, h ); | |
| 112 | graphics.setColor( new Color( 255, 204, 204 ) ); | |
| 113 | graphics.setStroke( new BasicStroke( 4 ) ); | |
| 114 | graphics.drawOval( w / 4, h / 4, w / 2, h / 2 ); | |
| 115 | graphics.drawLine( w / 4 + (int) (w / 4 / Math.PI), | |
| 116 | h / 4 + (int) (w / 4 / Math.PI), | |
| 117 | w / 2 + w / 4 - (int) (w / 4 / Math.PI), | |
| 118 | h / 2 + h / 4 - (int) (w / 4 / Math.PI) ); | |
| 119 | } | |
| 120 | ||
| 121 | BROKEN_IMAGE_PLACEHOLDER = image; | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Responsible for creating a new {@link ImageRenderer} implementation that | |
| 126 | * can render a DOM as an SVG image. | |
| 127 | */ | |
| 128 | private static class BufferedImageTranscoder extends ImageTranscoder { | |
| 129 | private BufferedImage mImage; | |
| 130 | ||
| 131 | @Override | |
| 132 | public BufferedImage createImage( final int w, final int h ) { | |
| 133 | return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB ); | |
| 134 | } | |
| 135 | ||
| 136 | @Override | |
| 137 | public void writeImage( | |
| 138 | final BufferedImage image, final TranscoderOutput output ) { | |
| 139 | mImage = image; | |
| 140 | } | |
| 141 | ||
| 142 | public BufferedImage getImage() { | |
| 143 | return mImage; | |
| 144 | } | |
| 145 | ||
| 146 | @Override | |
| 147 | protected ImageRenderer createRenderer() { | |
| 148 | final ImageRenderer renderer = super.createRenderer(); | |
| 149 | final RenderingHints hints = renderer.getRenderingHints(); | |
| 150 | hints.putAll( RENDERING_HINTS ); | |
| 151 | renderer.setRenderingHints( hints ); | |
| 152 | ||
| 153 | return renderer; | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Rasterizes the given SVG input stream into an image at 96 DPI. | |
| 159 | * | |
| 160 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 161 | * @return The given input stream converted to a rasterized image. | |
| 162 | */ | |
| 163 | public static BufferedImage rasterize( final InputStream svg ) | |
| 164 | throws TranscoderException { | |
| 165 | return rasterize( svg, 96 ); | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * Rasterizes the given SVG input stream into an image. | |
| 170 | * | |
| 171 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 172 | * @param dpi Resolution to use when rasterizing (default is 96 DPI). | |
| 173 | * @return The given input stream converted to a rasterized image at the | |
| 174 | * given resolution. | |
| 175 | */ | |
| 176 | public static BufferedImage rasterize( | |
| 177 | final InputStream svg, final float dpi ) throws TranscoderException { | |
| 178 | final var transcoder = new BufferedImageTranscoder(); | |
| 179 | transcoder.addTranscodingHint( | |
| 180 | KEY_PIXEL_UNIT_TO_MILLIMETER, 1f / dpi * 25.4f ); | |
| 181 | transcoder.transcode( new TranscoderInput( svg ), null ); | |
| 182 | return transcoder.getImage(); | |
| 183 | } | |
| 184 | ||
| 185 | /** | |
| 186 | * Rasterizes the given document into an image. | |
| 187 | * | |
| 188 | * @param svg The SVG {@link Document} to rasterize. | |
| 189 | * @param width The rasterized image's width (in pixels). | |
| 190 | * @return The rasterized image. | |
| 191 | */ | |
| 192 | public static BufferedImage rasterize( final Document svg, final int width ) | |
| 193 | throws TranscoderException { | |
| 194 | final var transcoder = new BufferedImageTranscoder(); | |
| 195 | transcoder.addTranscodingHint( KEY_WIDTH, (float) width ); | |
| 196 | transcoder.transcode( new TranscoderInput( svg ), null ); | |
| 197 | return transcoder.getImage(); | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * Rasterizes the given vector graphic file using the width dimension | |
| 202 | * specified by the document's width attribute. | |
| 203 | * | |
| 204 | * @param document The {@link Document} containing a vector graphic. | |
| 205 | * @return A rasterized image as an instance of {@link BufferedImage}, or | |
| 206 | * {@link #BROKEN_IMAGE_PLACEHOLDER} if the graphic could not be rasterized. | |
| 207 | */ | |
| 208 | public static BufferedImage rasterize( final Document document ) | |
| 209 | throws ParseException, TranscoderException { | |
| 210 | final var root = document.getDocumentElement(); | |
| 211 | final var width = root.getAttribute( "width" ); | |
| 212 | return rasterize( document, INT_FORMAT.parse( width ).intValue() ); | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 217 | * happens, a broken image icon is returned instead. | |
| 218 | * | |
| 219 | * @param path The {@link Path} to a vector graphic file. | |
| 220 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 221 | * maintained. | |
| 222 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 223 | */ | |
| 224 | public static BufferedImage rasterize( final Path path, final int width ) { | |
| 225 | return rasterize( path.toUri(), width ); | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 230 | * happens, a broken image icon is returned instead. | |
| 231 | * | |
| 232 | * @param uri The URI to a vector graphic file, which must include the | |
| 233 | * protocol scheme (such as file:// or https://). | |
| 234 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 235 | * maintained. | |
| 236 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 237 | */ | |
| 238 | public static BufferedImage rasterize( final String uri, final int width ) { | |
| 239 | return rasterize( new File( uri ).toURI(), width ); | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * Converts an SVG drawing into a rasterized image that can be drawn on | |
| 244 | * a graphics context. | |
| 245 | * | |
| 246 | * @param uri The path to the image (can be web address). | |
| 247 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 248 | * maintained. | |
| 249 | * @return The vector graphic transcoded into a raster image format. | |
| 250 | */ | |
| 251 | public static BufferedImage rasterize( final URI uri, final int width ) { | |
| 252 | try { | |
| 253 | return rasterize( FACTORY_DOM.createDocument( uri.toString() ), width ); | |
| 254 | } catch( final Exception ex ) { | |
| 255 | clue( ex ); | |
| 256 | } | |
| 257 | ||
| 258 | return BROKEN_IMAGE_PLACEHOLDER; | |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 263 | * a graphics context. The dimensions are determined from the document. | |
| 264 | * | |
| 265 | * @param xml The SVG xml document. | |
| 266 | * @return The vector graphic transcoded into a raster image format. | |
| 267 | */ | |
| 268 | public static BufferedImage rasterizeString( final String xml ) | |
| 269 | throws ParseException, TranscoderException { | |
| 270 | final var document = toDocument( xml ); | |
| 271 | final var root = document.getDocumentElement(); | |
| 272 | final var width = root.getAttribute( "width" ); | |
| 273 | return rasterizeString( xml, INT_FORMAT.parse( width ).intValue() ); | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 278 | * a graphics context. | |
| 279 | * | |
| 280 | * @param svg The SVG xml document. | |
| 281 | * @param w Scale the image width to this size (aspect ratio is | |
| 282 | * maintained). | |
| 283 | * @return The vector graphic transcoded into a raster image format. | |
| 284 | */ | |
| 285 | public static BufferedImage rasterizeString( final String svg, final int w ) | |
| 286 | throws TranscoderException { | |
| 287 | return rasterize( toDocument( svg ), w ); | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Given a document object model (DOM) {@link Element}, this will convert that | |
| 292 | * element to a string. | |
| 293 | * | |
| 294 | * @param root The DOM node to convert to a string. | |
| 295 | * @return The DOM node as an escaped, plain text string. | |
| 296 | */ | |
| 297 | public static String toSvg( final Element root ) { | |
| 298 | try { | |
| 299 | return transform( root ).replaceAll( "xmlns=\"\" ", "" ); | |
| 300 | } catch( final Exception ex ) { | |
| 301 | clue( ex ); | |
| 302 | } | |
| 303 | ||
| 304 | return BROKEN_IMAGE_SVG; | |
| 305 | } | |
| 306 | ||
| 307 | /** | |
| 308 | * Converts an SVG XML string into a new {@link Document} instance. | |
| 309 | * | |
| 310 | * @param xml The XML containing SVG elements. | |
| 311 | * @return The SVG contents parsed into a {@link Document} object model. | |
| 312 | */ | |
| 313 | private static Document toDocument( final String xml ) { | |
| 314 | try( final var reader = new StringReader( xml ) ) { | |
| 315 | return FACTORY_DOM.createSVGDocument( | |
| 316 | "http://www.w3.org/2000/svg", reader ); | |
| 317 | } catch( final Exception ex ) { | |
| 318 | throw new IllegalArgumentException( ex ); | |
| 319 | } | |
| 5 | import org.apache.batik.bridge.BridgeContext; | |
| 6 | import org.apache.batik.bridge.DocumentLoader; | |
| 7 | import org.apache.batik.bridge.UserAgent; | |
| 8 | import org.apache.batik.bridge.UserAgentAdapter; | |
| 9 | import org.apache.batik.css.parser.Parser; | |
| 10 | import org.apache.batik.gvt.renderer.ImageRenderer; | |
| 11 | import org.apache.batik.transcoder.TranscoderException; | |
| 12 | import org.apache.batik.transcoder.TranscoderInput; | |
| 13 | import org.apache.batik.transcoder.TranscoderOutput; | |
| 14 | import org.apache.batik.transcoder.image.ImageTranscoder; | |
| 15 | import org.apache.batik.util.XMLResourceDescriptor; | |
| 16 | import org.w3c.css.sac.CSSException; | |
| 17 | import org.w3c.dom.Document; | |
| 18 | import org.w3c.dom.Element; | |
| 19 | ||
| 20 | import java.awt.*; | |
| 21 | import java.awt.image.BufferedImage; | |
| 22 | import java.io.File; | |
| 23 | import java.io.IOException; | |
| 24 | import java.io.InputStream; | |
| 25 | import java.io.StringReader; | |
| 26 | import java.net.URI; | |
| 27 | import java.nio.file.Path; | |
| 28 | import java.text.NumberFormat; | |
| 29 | import java.text.ParseException; | |
| 30 | ||
| 31 | import static com.keenwrite.dom.DocumentParser.transform; | |
| 32 | import static com.keenwrite.events.StatusEvent.clue; | |
| 33 | import static com.keenwrite.preview.HighQualityRenderingHints.RENDERING_HINTS; | |
| 34 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; | |
| 35 | import static java.text.NumberFormat.getIntegerInstance; | |
| 36 | import static org.apache.batik.bridge.UnitProcessor.createContext; | |
| 37 | import static org.apache.batik.bridge.UnitProcessor.svgHorizontalLengthToUserSpace; | |
| 38 | import static org.apache.batik.transcoder.SVGAbstractTranscoder.KEY_WIDTH; | |
| 39 | import static org.apache.batik.transcoder.TranscodingHints.Key; | |
| 40 | import static org.apache.batik.transcoder.image.ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER; | |
| 41 | import static org.apache.batik.util.SVGConstants.SVG_WIDTH_ATTRIBUTE; | |
| 42 | import static org.apache.batik.util.XMLResourceDescriptor.getXMLParserClassName; | |
| 43 | ||
| 44 | /** | |
| 45 | * Responsible for converting SVG images into rasterized PNG images. | |
| 46 | */ | |
| 47 | public final class SvgRasterizer { | |
| 48 | /** | |
| 49 | * <a href="https://issues.apache.org/jira/browse/BATIK-1112">Bug fix</a> | |
| 50 | */ | |
| 51 | public static final class InkscapeCssParser extends Parser { | |
| 52 | public void parseStyleDeclaration( final String source ) | |
| 53 | throws CSSException, IOException { | |
| 54 | super.parseStyleDeclaration( | |
| 55 | source.replaceAll( "-inkscape-font-specification:[^;\"]*;", "" ) | |
| 56 | ); | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | static { | |
| 61 | XMLResourceDescriptor.setCSSParserClassName( | |
| 62 | InkscapeCssParser.class.getName() | |
| 63 | ); | |
| 64 | } | |
| 65 | ||
| 66 | private static final UserAgent USER_AGENT = new UserAgentAdapter(); | |
| 67 | private static final BridgeContext BRIDGE_CONTEXT = new BridgeContext( | |
| 68 | USER_AGENT, new DocumentLoader( USER_AGENT ) | |
| 69 | ); | |
| 70 | ||
| 71 | private static final SAXSVGDocumentFactory FACTORY_DOM = | |
| 72 | new SAXSVGDocumentFactory( getXMLParserClassName() ); | |
| 73 | ||
| 74 | private static final NumberFormat INT_FORMAT = getIntegerInstance(); | |
| 75 | ||
| 76 | public static final BufferedImage BROKEN_IMAGE_PLACEHOLDER; | |
| 77 | ||
| 78 | /** | |
| 79 | * A FontAwesome camera icon, cleft asunder. | |
| 80 | */ | |
| 81 | public static final String BROKEN_IMAGE_SVG = | |
| 82 | "<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www" + | |
| 83 | ".w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c" + | |
| 84 | ".332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path " + | |
| 85 | "d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531" + | |
| 86 | ".03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2" + | |
| 87 | ".511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-" + | |
| 88 | ".367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1" + | |
| 89 | ".699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 " + | |
| 90 | "2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094" + | |
| 91 | ".0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5" + | |
| 92 | ".0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-" + | |
| 93 | ".195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4" + | |
| 94 | ".191406-3.652344.207031-.015625.414063-.015625.617187-.007812l" + | |
| 95 | ".933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2" + | |
| 96 | ".820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3" + | |
| 97 | ".429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3" + | |
| 98 | ".429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 " + | |
| 99 | ".140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281" + | |
| 100 | ".808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2" + | |
| 101 | ".472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4" + | |
| 102 | ".285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1" + | |
| 103 | ".9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-" + | |
| 104 | ".667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1" + | |
| 105 | ".253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 " + | |
| 106 | "0'/></g></svg>"; | |
| 107 | ||
| 108 | static { | |
| 109 | // The width and height cannot be embedded in the SVG above because the | |
| 110 | // path element values are relative to the viewBox dimensions. | |
| 111 | final int w = 75; | |
| 112 | final int h = 75; | |
| 113 | BufferedImage image; | |
| 114 | ||
| 115 | try { | |
| 116 | image = rasterizeString( BROKEN_IMAGE_SVG, w ); | |
| 117 | } catch( final Exception ex ) { | |
| 118 | image = new BufferedImage( w, h, TYPE_INT_RGB ); | |
| 119 | final var graphics = (Graphics2D) image.getGraphics(); | |
| 120 | graphics.setRenderingHints( RENDERING_HINTS ); | |
| 121 | ||
| 122 | // Fall back to a (\) symbol. | |
| 123 | graphics.setColor( new Color( 204, 204, 204 ) ); | |
| 124 | graphics.fillRect( 0, 0, w, h ); | |
| 125 | graphics.setColor( new Color( 255, 204, 204 ) ); | |
| 126 | graphics.setStroke( new BasicStroke( 4 ) ); | |
| 127 | graphics.drawOval( w / 4, h / 4, w / 2, h / 2 ); | |
| 128 | graphics.drawLine( w / 4 + (int) (w / 4 / Math.PI), | |
| 129 | h / 4 + (int) (w / 4 / Math.PI), | |
| 130 | w / 2 + w / 4 - (int) (w / 4 / Math.PI), | |
| 131 | h / 2 + h / 4 - (int) (w / 4 / Math.PI) ); | |
| 132 | } | |
| 133 | ||
| 134 | BROKEN_IMAGE_PLACEHOLDER = image; | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * Responsible for creating a new {@link ImageRenderer} implementation that | |
| 139 | * can render a DOM as an SVG image. | |
| 140 | */ | |
| 141 | private static class BufferedImageTranscoder extends ImageTranscoder { | |
| 142 | private BufferedImage mImage; | |
| 143 | ||
| 144 | @Override | |
| 145 | public BufferedImage createImage( final int w, final int h ) { | |
| 146 | return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB ); | |
| 147 | } | |
| 148 | ||
| 149 | @Override | |
| 150 | public void writeImage( | |
| 151 | final BufferedImage image, final TranscoderOutput output ) { | |
| 152 | mImage = image; | |
| 153 | } | |
| 154 | ||
| 155 | public BufferedImage getImage() { | |
| 156 | return mImage; | |
| 157 | } | |
| 158 | ||
| 159 | @Override | |
| 160 | protected ImageRenderer createRenderer() { | |
| 161 | final ImageRenderer renderer = super.createRenderer(); | |
| 162 | final RenderingHints hints = renderer.getRenderingHints(); | |
| 163 | hints.putAll( RENDERING_HINTS ); | |
| 164 | renderer.setRenderingHints( hints ); | |
| 165 | ||
| 166 | return renderer; | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * Rasterizes the given SVG input stream into an image at 96 DPI. | |
| 172 | * | |
| 173 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 174 | * @return The given input stream converted to a rasterized image. | |
| 175 | */ | |
| 176 | public static BufferedImage rasterize( final InputStream svg ) | |
| 177 | throws TranscoderException { | |
| 178 | return rasterize( svg, 96 ); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Rasterizes the given SVG input stream into an image. | |
| 183 | * | |
| 184 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 185 | * @param dpi Resolution to use when rasterizing (default is 96 DPI). | |
| 186 | * @return The given input stream converted to a rasterized image at the | |
| 187 | * given resolution. | |
| 188 | */ | |
| 189 | public static BufferedImage rasterize( | |
| 190 | final InputStream svg, final float dpi ) throws TranscoderException { | |
| 191 | return rasterize( | |
| 192 | new TranscoderInput( svg ), | |
| 193 | KEY_PIXEL_UNIT_TO_MILLIMETER, | |
| 194 | 1f / dpi * 25.4f | |
| 195 | ); | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Rasterizes the given document into an image. | |
| 200 | * | |
| 201 | * @param svg The SVG {@link Document} to rasterize. | |
| 202 | * @param width The rasterized image's width (in pixels). | |
| 203 | * @return The rasterized image. | |
| 204 | */ | |
| 205 | public static BufferedImage rasterize( | |
| 206 | final Document svg, final int width ) throws TranscoderException { | |
| 207 | return rasterize( | |
| 208 | new TranscoderInput( svg ), | |
| 209 | KEY_WIDTH, | |
| 210 | fit( svg.getDocumentElement(), width ) | |
| 211 | ); | |
| 212 | } | |
| 213 | ||
| 214 | /** | |
| 215 | * Rasterizes the given vector graphic file using the width dimension | |
| 216 | * specified by the document's width attribute. | |
| 217 | * | |
| 218 | * @param document The {@link Document} containing a vector graphic. | |
| 219 | * @return A rasterized image as an instance of {@link BufferedImage}, or | |
| 220 | * {@link #BROKEN_IMAGE_PLACEHOLDER} if the graphic could not be rasterized. | |
| 221 | */ | |
| 222 | public static BufferedImage rasterize( final Document document ) | |
| 223 | throws ParseException, TranscoderException { | |
| 224 | final var root = document.getDocumentElement(); | |
| 225 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 226 | return rasterize( document, INT_FORMAT.parse( width ).intValue() ); | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 231 | * happens, a broken image icon is returned instead. | |
| 232 | * | |
| 233 | * @param path The {@link Path} to a vector graphic file. | |
| 234 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 235 | * maintained. | |
| 236 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 237 | */ | |
| 238 | public static BufferedImage rasterize( final Path path, final int width ) { | |
| 239 | return rasterize( path.toUri(), width ); | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 244 | * happens, a broken image icon is returned instead. | |
| 245 | * | |
| 246 | * @param uri The URI to a vector graphic file, which must include the | |
| 247 | * protocol scheme (such as file:// or https://). | |
| 248 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 249 | * maintained. | |
| 250 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 251 | */ | |
| 252 | public static BufferedImage rasterize( final String uri, final int width ) { | |
| 253 | return rasterize( new File( uri ).toURI(), width ); | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Converts an SVG drawing into a rasterized image that can be drawn on | |
| 258 | * a graphics context. | |
| 259 | * | |
| 260 | * @param uri The path to the image (can be web address). | |
| 261 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 262 | * maintained. | |
| 263 | * @return The vector graphic transcoded into a raster image format. | |
| 264 | */ | |
| 265 | public static BufferedImage rasterize( final URI uri, final int width ) { | |
| 266 | try { | |
| 267 | return rasterize( FACTORY_DOM.createDocument( uri.toString() ), width ); | |
| 268 | } catch( final Exception ex ) { | |
| 269 | clue( ex ); | |
| 270 | } | |
| 271 | ||
| 272 | return BROKEN_IMAGE_PLACEHOLDER; | |
| 273 | } | |
| 274 | ||
| 275 | /** | |
| 276 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 277 | * a graphics context. The dimensions are determined from the document. | |
| 278 | * | |
| 279 | * @param xml The SVG xml document. | |
| 280 | * @return The vector graphic transcoded into a raster image format. | |
| 281 | */ | |
| 282 | public static BufferedImage rasterizeString( final String xml ) | |
| 283 | throws ParseException, TranscoderException { | |
| 284 | final var document = toDocument( xml ); | |
| 285 | final var root = document.getDocumentElement(); | |
| 286 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 287 | return rasterizeString( xml, INT_FORMAT.parse( width ).intValue() ); | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 292 | * a graphics context. | |
| 293 | * | |
| 294 | * @param svg The SVG xml document. | |
| 295 | * @param w Scale the image width to this size (aspect ratio is | |
| 296 | * maintained). | |
| 297 | * @return The vector graphic transcoded into a raster image format. | |
| 298 | */ | |
| 299 | public static BufferedImage rasterizeString( final String svg, final int w ) | |
| 300 | throws TranscoderException { | |
| 301 | return rasterize( toDocument( svg ), w ); | |
| 302 | } | |
| 303 | ||
| 304 | /** | |
| 305 | * Given a document object model (DOM) {@link Element}, this will convert that | |
| 306 | * element to a string. | |
| 307 | * | |
| 308 | * @param root The DOM node to convert to a string. | |
| 309 | * @return The DOM node as an escaped, plain text string. | |
| 310 | */ | |
| 311 | public static String toSvg( final Element root ) { | |
| 312 | try { | |
| 313 | return transform( root ).replaceAll( "xmlns=\"\" ", "" ); | |
| 314 | } catch( final Exception ex ) { | |
| 315 | clue( ex ); | |
| 316 | } | |
| 317 | ||
| 318 | return BROKEN_IMAGE_SVG; | |
| 319 | } | |
| 320 | ||
| 321 | /** | |
| 322 | * Converts an SVG XML string into a new {@link Document} instance. | |
| 323 | * | |
| 324 | * @param xml The XML containing SVG elements. | |
| 325 | * @return The SVG contents parsed into a {@link Document} object model. | |
| 326 | */ | |
| 327 | private static Document toDocument( final String xml ) { | |
| 328 | try( final var reader = new StringReader( xml ) ) { | |
| 329 | return FACTORY_DOM.createSVGDocument( | |
| 330 | "http://www.w3.org/2000/svg", reader ); | |
| 331 | } catch( final Exception ex ) { | |
| 332 | throw new IllegalArgumentException( ex ); | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Creates a rasterized image of the given source document. | |
| 338 | * | |
| 339 | * @param input The source document to transcode. | |
| 340 | * @param key Transcoding hint key. | |
| 341 | * @param width Transcoding hint value. | |
| 342 | * @return A new {@link BufferedImageTranscoder} instance with the given | |
| 343 | * transcoding hint applied. | |
| 344 | */ | |
| 345 | private static BufferedImage rasterize( | |
| 346 | final TranscoderInput input, final Key key, final float width ) | |
| 347 | throws TranscoderException { | |
| 348 | final var transcoder = new BufferedImageTranscoder(); | |
| 349 | ||
| 350 | transcoder.addTranscodingHint( key, width ); | |
| 351 | transcoder.transcode( input, null ); | |
| 352 | ||
| 353 | return transcoder.getImage(); | |
| 354 | } | |
| 355 | ||
| 356 | /** | |
| 357 | * Returns either the given element's SVG document width, or the display | |
| 358 | * width, whichever is smaller. | |
| 359 | * | |
| 360 | * @param root The SVG document's root node. | |
| 361 | * @param width The display width (e.g., rendering canvas width). | |
| 362 | * @return The lower value of the document's width or the display width. | |
| 363 | */ | |
| 364 | private static float fit( final Element root, final int width ) { | |
| 365 | final var w = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 366 | ||
| 367 | return w == null || w.isBlank() ? width : fit( root, w, width ); | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * Returns the width in user space units (pixels?). | |
| 372 | * | |
| 373 | * @param root The element containing the width attribute. | |
| 374 | * @param w The element's width attribute value. | |
| 375 | * @param width The rendering canvas width. | |
| 376 | * @return Either the rendering canvas width or SVG document width, | |
| 377 | * whichever is smaller. | |
| 378 | */ | |
| 379 | private static float fit( | |
| 380 | final Element root, final String w, final int width ) { | |
| 381 | final var usWidth = svgHorizontalLengthToUserSpace( | |
| 382 | w, SVG_WIDTH_ATTRIBUTE, createContext( BRIDGE_CONTEXT, root ) | |
| 383 | ); | |
| 384 | ||
| 385 | // If the image is too small, scale it to 1/4 the canvas width. | |
| 386 | return Math.min( usWidth < 5 ? width / 4.0f : usWidth, (float) width ); | |
| 320 | 387 | } |
| 321 | 388 | } |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | static class Factory implements @NotNull NodeRendererFactory { | |
| 46 | static class Factory implements NodeRendererFactory { | |
| 47 | 47 | @Override |
| 48 | 48 | public @NotNull NodeRenderer apply( @NotNull final DataHolder options ) { |
| 23 | 23 | import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER; |
| 24 | 24 | import static com.keenwrite.preview.SvgRasterizer.rasterize; |
| 25 | import static java.awt.Font.BOLD; | |
| 25 | import static java.awt.Font.*; | |
| 26 | 26 | import static java.nio.file.Files.readAttributes; |
| 27 | 27 | import static javafx.embed.swing.SwingFXUtils.toFXImage; |
| ... | ||
| 112 | 112 | } |
| 113 | 113 | |
| 114 | if(extension == null) { | |
| 114 | if( extension == null ) { | |
| 115 | 115 | extension = ""; |
| 116 | 116 | } |
| ... | ||
| 158 | 158 | } |
| 159 | 159 | |
| 160 | /** | |
| 161 | * Returns the font to use when adding icons to the UI. | |
| 162 | * | |
| 163 | * @param size The font size to use when drawing the icon. | |
| 164 | * @return A font containing numerous icons. | |
| 165 | */ | |
| 160 | 166 | public static Font getIconFont( final int size ) { |
| 161 | return new Font( FONT_AWESOME.getName(), BOLD, size ); | |
| 167 | try( final var fontStream = openFont() ) { | |
| 168 | final var font = createFont( TRUETYPE_FONT, fontStream ); | |
| 169 | return font.deriveFont( PLAIN, size ); | |
| 170 | } catch( final Exception e ) { | |
| 171 | // This doesn't actually work, seemingly after an upgrade to ControlsFX. | |
| 172 | // As such, creating the font and deriving it will work. | |
| 173 | return new Font( FONT_AWESOME.getName(), PLAIN, size ); | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * This re-reads the {@link FontAwesome} font TTF resource. For a reason | |
| 179 | * not yet investigated, the font doesn't appear to be accessible to the | |
| 180 | * application. This may have happened during an upgrade to ControlsFX. | |
| 181 | * Callers are responsible for closing the stream. | |
| 182 | * | |
| 183 | * @return A stream containing font TrueType glyph information. | |
| 184 | */ | |
| 185 | private static InputStream openFont() { | |
| 186 | return FontAwesome.class.getResourceAsStream( "fontawesome-webfont.ttf" ); | |
| 162 | 187 | } |
| 163 | 188 | |
| 4 | 4 | import com.keenwrite.events.DocumentChangedEvent; |
| 5 | 5 | import com.keenwrite.preferences.Workspace; |
| 6 | import com.keenwrite.preview.HtmlPanel; | |
| 7 | 6 | import com.whitemagicsoftware.wordcount.TokenizerException; |
| 8 | 7 | import javafx.beans.property.IntegerProperty; |
| ... | ||
| 68 | 67 | * Called when the hash code for the current document changes. This happens |
| 69 | 68 | * when non-collapsable-whitespace is added to the document. When the |
| 70 | * document is sent to {@link HtmlPanel} for rendering, the parsed document | |
| 71 | * is converted to text. If that text differs in its hash code, then this | |
| 72 | * method is called. The implication is that all variables and executable | |
| 73 | * statements have been replaced. An event bus subscriber is used so that | |
| 74 | * text processing occurs outside of the UI processing threads. | |
| 69 | * document is sent for rendering, the parsed document is converted to text. | |
| 70 | * If that text differs in its hash code, then this method is called. The | |
| 71 | * implication is that all variables and executable statements have been | |
| 72 | * replaced. An event bus subscriber is used so that text processing occurs | |
| 73 | * outside the UI processing threads. | |
| 75 | 74 | * |
| 76 | 75 | * @param event Container for the document text that has changed. |
| 41 | 41 | /** |
| 42 | 42 | * Number of error messages to retain in the {@link TableView}; must be |
| 43 | * greater than zero. | |
| 43 | * greater than zero. Typesetting the document can cause many page number | |
| 44 | * messages to be logged. | |
| 44 | 45 | */ |
| 45 | private static final int CACHE_SIZE = 150; | |
| 46 | private static final int CACHE_SIZE = 500; | |
| 46 | 47 | |
| 47 | 48 | private final ObservableList<LogEntry> mItems = observableArrayList(); |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.util; | |
| 3 | ||
| 4 | import static java.lang.Math.max; | |
| 5 | ||
| 6 | /** | |
| 7 | * Scans an array (haystack) for a particular value (needle). | |
| 8 | * | |
| 9 | * <p> | |
| 10 | * This class is {@code null}-hostile. | |
| 11 | */ | |
| 12 | public class ArrayScanner { | |
| 13 | ||
| 14 | /** | |
| 15 | * The index value returned when an element is not found in an array. | |
| 16 | */ | |
| 17 | public static final int MISSING = -1; | |
| 18 | ||
| 19 | /** | |
| 20 | * Finds the index of the given needle in the haystack. | |
| 21 | * | |
| 22 | * @param haystack The haystack to search through for the needle. | |
| 23 | * @param needle The needle to find in the haystack. | |
| 24 | * @return Index of the needle within the haystack, or {@link #MISSING} | |
| 25 | * if not found. | |
| 26 | */ | |
| 27 | public static int indexOf( final Object[] haystack, final Object needle ) { | |
| 28 | assert haystack != null; | |
| 29 | assert needle != null; | |
| 30 | ||
| 31 | return indexOf( haystack, needle, 0 ); | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Finds the index of the given needle in the haystack. | |
| 36 | * | |
| 37 | * @param haystack The haystack to search through for the needle. | |
| 38 | * @param needle The needle to find in the haystack. | |
| 39 | * @param offset The starting offset into the haystack to begin looking | |
| 40 | * (the value may be greater than or less than the number | |
| 41 | * of array elements). | |
| 42 | * @return Index of the needle within the haystack, or {@link #MISSING} | |
| 43 | * if not found. | |
| 44 | */ | |
| 45 | public static int indexOf( | |
| 46 | final Object[] haystack, final Object needle, int offset ) { | |
| 47 | assert haystack != null; | |
| 48 | assert needle != null; | |
| 49 | ||
| 50 | for( int i = max( 0, offset ); i < haystack.length; i++ ) { | |
| 51 | if( needle.equals( haystack[ i ] ) ) { | |
| 52 | return i; | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | return MISSING; | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * Checks if the object is in the given array. | |
| 61 | * | |
| 62 | * @param haystack The haystack to search through for the needle. | |
| 63 | * @param needle The needle to find in the haystack. | |
| 64 | * @return {@code true} if the array contains the object. | |
| 65 | */ | |
| 66 | public static boolean contains( | |
| 67 | final Object[] haystack, final Object needle ) { | |
| 68 | assert haystack != null; | |
| 69 | assert needle != null; | |
| 70 | ||
| 71 | return indexOf( haystack, needle ) != MISSING; | |
| 72 | } | |
| 73 | } | |
| 1 | 74 |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 | |
| 4 | import com.keenwrite.preview.HtmlPreview; | |
| 5 | ||
| 6 | 4 | import java.awt.*; |
| 7 | 5 | import java.awt.font.TextAttribute; |
| ... | ||
| 22 | 20 | |
| 23 | 21 | /** |
| 24 | * Responsible for loading fonts into the application's | |
| 25 | * {@link GraphicsEnvironment} so that the {@link HtmlPreview} can display | |
| 26 | * the text using a non-system font. | |
| 22 | * Loads fonts into the application's {@link GraphicsEnvironment} so that | |
| 23 | * preview can display text using non-system fonts. | |
| 27 | 24 | */ |
| 28 | 25 | public final class FontLoader { |
| 122 | 122 | /* PREFORMATTED CODE ***/ |
| 123 | 123 | pre, code, tt { |
| 124 | /* Must be bundled in JAR file. */ | |
| 125 | 124 | font-family: 'Source Code Pro'; |
| 126 | font-size: 10pt; | |
| 125 | font-size: 13px; | |
| 127 | 126 | background-color: #f8f8f8; |
| 128 | 127 | text-decoration: none; |