001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.help; 003 004import static org.openstreetmap.josm.gui.help.HelpUtil.buildAbsoluteHelpTopic; 005import static org.openstreetmap.josm.gui.help.HelpUtil.getHelpTopicEditUrl; 006import static org.openstreetmap.josm.tools.I18n.tr; 007 008import java.awt.BorderLayout; 009import java.awt.Dimension; 010import java.awt.Rectangle; 011import java.awt.event.ActionEvent; 012import java.awt.event.KeyEvent; 013import java.awt.event.WindowAdapter; 014import java.awt.event.WindowEvent; 015import java.io.BufferedReader; 016import java.io.InputStreamReader; 017import java.io.StringReader; 018import java.nio.charset.StandardCharsets; 019import java.util.Locale; 020import java.util.Observable; 021import java.util.Observer; 022 023import javax.swing.AbstractAction; 024import javax.swing.JButton; 025import javax.swing.JComponent; 026import javax.swing.JDialog; 027import javax.swing.JMenuItem; 028import javax.swing.JOptionPane; 029import javax.swing.JPanel; 030import javax.swing.JScrollPane; 031import javax.swing.JSeparator; 032import javax.swing.JToolBar; 033import javax.swing.KeyStroke; 034import javax.swing.SwingUtilities; 035import javax.swing.event.HyperlinkEvent; 036import javax.swing.event.HyperlinkListener; 037import javax.swing.text.AttributeSet; 038import javax.swing.text.BadLocationException; 039import javax.swing.text.Document; 040import javax.swing.text.Element; 041import javax.swing.text.SimpleAttributeSet; 042import javax.swing.text.html.HTML.Tag; 043import javax.swing.text.html.HTMLDocument; 044import javax.swing.text.html.StyleSheet; 045 046import org.openstreetmap.josm.Main; 047import org.openstreetmap.josm.actions.JosmAction; 048import org.openstreetmap.josm.gui.HelpAwareOptionPane; 049import org.openstreetmap.josm.gui.MainMenu; 050import org.openstreetmap.josm.gui.widgets.JosmEditorPane; 051import org.openstreetmap.josm.gui.widgets.JosmHTMLEditorKit; 052import org.openstreetmap.josm.tools.ImageProvider; 053import org.openstreetmap.josm.tools.LanguageInfo.LocaleType; 054import org.openstreetmap.josm.tools.OpenBrowser; 055import org.openstreetmap.josm.tools.WindowGeometry; 056 057/** 058 * Help browser displaying HTML pages fetched from JOSM wiki. 059 */ 060public class HelpBrowser extends JDialog { 061 /** the unique instance */ 062 private static HelpBrowser instance; 063 064 /** the menu item in the windows menu. Required to properly 065 * hide on dialog close. 066 */ 067 private JMenuItem windowMenuItem; 068 069 /** 070 * Replies the unique instance of the help browser 071 * 072 * @return the unique instance of the help browser 073 */ 074 public static HelpBrowser getInstance() { 075 if (instance == null) { 076 instance = new HelpBrowser(); 077 } 078 return instance; 079 } 080 081 /** 082 * Show the help page for help topic <code>helpTopic</code>. 083 * 084 * @param helpTopic the help topic 085 */ 086 public static void setUrlForHelpTopic(final String helpTopic) { 087 final HelpBrowser browser = getInstance(); 088 Runnable r = new Runnable() { 089 @Override 090 public void run() { 091 browser.openHelpTopic(helpTopic); 092 browser.setVisible(true); 093 browser.toFront(); 094 } 095 }; 096 SwingUtilities.invokeLater(r); 097 } 098 099 /** 100 * Launches the internal help browser and directs it to the help page for 101 * <code>helpTopic</code>. 102 * 103 * @param helpTopic the help topic 104 */ 105 public static void launchBrowser(String helpTopic) { 106 HelpBrowser browser = getInstance(); 107 browser.openHelpTopic(helpTopic); 108 browser.setVisible(true); 109 browser.toFront(); 110 } 111 112 /** the help browser */ 113 private JosmEditorPane help; 114 115 /** the help browser history */ 116 private HelpBrowserHistory history; 117 118 /** the currently displayed URL */ 119 private String url; 120 121 private HelpContentReader reader; 122 123 private static final JosmAction focusAction = new JosmAction(tr("JOSM Help Browser"), "help", "", null, false, false) { 124 @Override 125 public void actionPerformed(ActionEvent e) { 126 HelpBrowser.getInstance().setVisible(true); 127 } 128 }; 129 130 /** 131 * Builds the style sheet used in the internal help browser 132 * 133 * @return the style sheet 134 */ 135 protected StyleSheet buildStyleSheet() { 136 StyleSheet ss = new StyleSheet(); 137 StringBuilder css = new StringBuilder(); 138 try (BufferedReader reader = new BufferedReader( 139 new InputStreamReader( 140 getClass().getResourceAsStream("/data/help-browser.css"), StandardCharsets.UTF_8 141 ) 142 )) { 143 String line = null; 144 while ((line = reader.readLine()) != null) { 145 css.append(line); 146 css.append("\n"); 147 } 148 } catch (Exception e) { 149 Main.error(tr("Failed to read CSS file ''help-browser.css''. Exception is: {0}", e.toString())); 150 Main.error(e); 151 return ss; 152 } 153 ss.addRule(css.toString()); 154 return ss; 155 } 156 157 protected JToolBar buildToolBar() { 158 JToolBar tb = new JToolBar(); 159 tb.add(new JButton(new HomeAction())); 160 tb.add(new JButton(new BackAction(history))); 161 tb.add(new JButton(new ForwardAction(history))); 162 tb.add(new JButton(new ReloadAction())); 163 tb.add(new JSeparator()); 164 tb.add(new JButton(new OpenInBrowserAction())); 165 tb.add(new JButton(new EditAction())); 166 return tb; 167 } 168 169 protected final void build() { 170 help = new JosmEditorPane(); 171 JosmHTMLEditorKit kit = new JosmHTMLEditorKit(); 172 kit.setStyleSheet(buildStyleSheet()); 173 help.setEditorKit(kit); 174 help.setEditable(false); 175 help.addHyperlinkListener(new HyperlinkHandler()); 176 help.setContentType("text/html"); 177 history = new HelpBrowserHistory(this); 178 179 JPanel p = new JPanel(new BorderLayout()); 180 setContentPane(p); 181 182 p.add(new JScrollPane(help), BorderLayout.CENTER); 183 184 addWindowListener(new WindowAdapter(){ 185 @Override public void windowClosing(WindowEvent e) { 186 setVisible(false); 187 } 188 }); 189 190 p.add(buildToolBar(), BorderLayout.NORTH); 191 help.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "Close"); 192 help.getActionMap().put("Close", new AbstractAction(){ 193 @Override 194 public void actionPerformed(ActionEvent e) { 195 setVisible(false); 196 } 197 }); 198 199 setMinimumSize(new Dimension(400, 200)); 200 setTitle(tr("JOSM Help Browser")); 201 } 202 203 @Override 204 public void setVisible(boolean visible) { 205 if (visible) { 206 new WindowGeometry( 207 getClass().getName() + ".geometry", 208 WindowGeometry.centerInWindow( 209 getParent(), 210 new Dimension(600,400) 211 ) 212 ).applySafe(this); 213 } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775 214 new WindowGeometry(this).remember(getClass().getName() + ".geometry"); 215 } 216 if (Main.main != null && Main.main.menu != null && Main.main.menu.windowMenu != null) { 217 if(windowMenuItem != null && !visible) { 218 Main.main.menu.windowMenu.remove(windowMenuItem); 219 windowMenuItem = null; 220 } 221 if(windowMenuItem == null && visible) { 222 windowMenuItem = MainMenu.add(Main.main.menu.windowMenu, focusAction, MainMenu.WINDOW_MENU_GROUP.VOLATILE); 223 } 224 } 225 super.setVisible(visible); 226 } 227 228 /** 229 * Constructs a new {@code HelpBrowser}. 230 */ 231 public HelpBrowser() { 232 reader = new HelpContentReader(HelpUtil.getWikiBaseUrl()); 233 build(); 234 } 235 236 protected void loadTopic(String content) { 237 Document document = help.getEditorKit().createDefaultDocument(); 238 try { 239 help.getEditorKit().read(new StringReader(content), document, 0); 240 } catch (Exception e) { 241 Main.error(e); 242 } 243 help.setDocument(document); 244 } 245 246 /** 247 * Replies the current URL 248 * 249 * @return the current URL 250 */ 251 public String getUrl() { 252 return url; 253 } 254 255 /** 256 * Displays a warning page when a help topic doesn't exist yet. 257 * 258 * @param relativeHelpTopic the help topic 259 */ 260 protected void handleMissingHelpContent(String relativeHelpTopic) { 261 // i18n: do not translate "warning-header" and "warning-body" 262 String message = tr("<html><p class=\"warning-header\">Help content for help topic missing</p>" 263 + "<p class=\"warning-body\">Help content for the help topic <strong>{0}</strong> is " 264 + "not available yet. It is missing both in your local language ({1}) and in English.<br><br>" 265 + "Please help to improve the JOSM help system and fill in the missing information. " 266 + "You can both edit the <a href=\"{2}\">help topic in your local language ({1})</a> and " 267 + "the <a href=\"{3}\">help topic in English</a>." 268 + "</p></html>", 269 relativeHelpTopic, 270 Locale.getDefault().getDisplayName(), 271 getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULT)), 272 getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)) 273 ); 274 loadTopic(message); 275 } 276 277 /** 278 * Displays a error page if a help topic couldn't be loaded because of network or IO error. 279 * 280 * @param relativeHelpTopic the help topic 281 * @param e the exception 282 */ 283 protected void handleHelpContentReaderException(String relativeHelpTopic, HelpContentReaderException e) { 284 String message = tr("<html><p class=\"error-header\">Error when retrieving help information</p>" 285 + "<p class=\"error-body\">The content for the help topic <strong>{0}</strong> could " 286 + "not be loaded. The error message is (untranslated):<br>" 287 + "<tt>{1}</tt>" 288 + "</p></html>", 289 relativeHelpTopic, 290 e.toString() 291 ); 292 loadTopic(message); 293 } 294 295 /** 296 * Loads a help topic given by a relative help topic name (i.e. "/Action/New") 297 * 298 * First tries to load the language specific help topic. If it is missing, tries to 299 * load the topic in English. 300 * 301 * @param relativeHelpTopic the relative help topic 302 */ 303 protected void loadRelativeHelpTopic(String relativeHelpTopic) { 304 String url = HelpUtil.getHelpTopicUrl(HelpUtil.buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULTNOTENGLISH)); 305 String content = null; 306 try { 307 content = reader.fetchHelpTopicContent(url, true); 308 } catch(MissingHelpContentException e) { 309 url = HelpUtil.getHelpTopicUrl(HelpUtil.buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.BASELANGUAGE)); 310 try { 311 content = reader.fetchHelpTopicContent(url, true); 312 } catch(MissingHelpContentException e1) { 313 url = HelpUtil.getHelpTopicUrl(HelpUtil.buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)); 314 try { 315 content = reader.fetchHelpTopicContent(url, true); 316 } catch(MissingHelpContentException e2) { 317 this.url = url; 318 handleMissingHelpContent(relativeHelpTopic); 319 return; 320 } catch(HelpContentReaderException e2) { 321 Main.error(e2); 322 handleHelpContentReaderException(relativeHelpTopic, e2); 323 return; 324 } 325 } catch(HelpContentReaderException e1) { 326 Main.error(e1); 327 handleHelpContentReaderException(relativeHelpTopic, e1); 328 return; 329 } 330 } catch(HelpContentReaderException e) { 331 Main.error(e); 332 handleHelpContentReaderException(relativeHelpTopic, e); 333 return; 334 } 335 loadTopic(content); 336 history.setCurrentUrl(url); 337 this.url = url; 338 } 339 340 /** 341 * Loads a help topic given by an absolute help topic name, i.e. 342 * "/De:Help/Action/New" 343 * 344 * @param absoluteHelpTopic the absolute help topic name 345 */ 346 protected void loadAbsoluteHelpTopic(String absoluteHelpTopic) { 347 String url = HelpUtil.getHelpTopicUrl(absoluteHelpTopic); 348 String content = null; 349 try { 350 content = reader.fetchHelpTopicContent(url, true); 351 } catch(MissingHelpContentException e) { 352 this.url = url; 353 handleMissingHelpContent(absoluteHelpTopic); 354 return; 355 } catch(HelpContentReaderException e) { 356 Main.error(e); 357 handleHelpContentReaderException(absoluteHelpTopic, e); 358 return; 359 } 360 loadTopic(content); 361 history.setCurrentUrl(url); 362 this.url = url; 363 } 364 365 /** 366 * Opens an URL and displays the content. 367 * 368 * If the URL is the locator of an absolute help topic, help content is loaded from 369 * the JOSM wiki. Otherwise, the help browser loads the page from the given URL 370 * 371 * @param url the url 372 */ 373 public void openUrl(String url) { 374 if (!isVisible()) { 375 setVisible(true); 376 toFront(); 377 } else { 378 toFront(); 379 } 380 String helpTopic = HelpUtil.extractAbsoluteHelpTopic(url); 381 if (helpTopic == null) { 382 try { 383 this.url = url; 384 String content = reader.fetchHelpTopicContent(url, false); 385 loadTopic(content); 386 history.setCurrentUrl(url); 387 this.url = url; 388 } catch(Exception e) { 389 Main.warn(e); 390 HelpAwareOptionPane.showOptionDialog( 391 Main.parent, 392 tr( 393 "<html>Failed to open help page for url {0}.<br>" 394 + "This is most likely due to a network problem, please check<br>" 395 + "your internet connection</html>", 396 url 397 ), 398 tr("Failed to open URL"), 399 JOptionPane.ERROR_MESSAGE, 400 null, /* no icon */ 401 null, /* standard options, just OK button */ 402 null, /* default is standard */ 403 null /* no help context */ 404 ); 405 } 406 history.setCurrentUrl(url); 407 } else { 408 loadAbsoluteHelpTopic(helpTopic); 409 } 410 } 411 412 /** 413 * Loads and displays the help information for a help topic given 414 * by a relative help topic name, i.e. "/Action/New" 415 * 416 * @param relativeHelpTopic the relative help topic 417 */ 418 public void openHelpTopic(String relativeHelpTopic) { 419 if (!isVisible()) { 420 setVisible(true); 421 toFront(); 422 } else { 423 toFront(); 424 } 425 loadRelativeHelpTopic(relativeHelpTopic); 426 } 427 428 class OpenInBrowserAction extends AbstractAction { 429 public OpenInBrowserAction() { 430 putValue(SHORT_DESCRIPTION, tr("Open the current help page in an external browser")); 431 putValue(SMALL_ICON, ImageProvider.get("help", "internet")); 432 } 433 434 @Override 435 public void actionPerformed(ActionEvent e) { 436 OpenBrowser.displayUrl(getUrl()); 437 } 438 } 439 440 class EditAction extends AbstractAction { 441 public EditAction() { 442 putValue(SHORT_DESCRIPTION, tr("Edit the current help page")); 443 putValue(SMALL_ICON,ImageProvider.get("dialogs", "edit")); 444 } 445 446 @Override 447 public void actionPerformed(ActionEvent e) { 448 String url = getUrl(); 449 if(url == null) 450 return; 451 if (!url.startsWith(HelpUtil.getWikiBaseHelpUrl())) { 452 String message = tr( 453 "<html>The current URL <tt>{0}</tt><br>" 454 + "is an external URL. Editing is only possible for help topics<br>" 455 + "on the help server <tt>{1}</tt>.</html>", 456 getUrl(), 457 HelpUtil.getWikiBaseUrl() 458 ); 459 JOptionPane.showMessageDialog( 460 Main.parent, 461 message, 462 tr("Warning"), 463 JOptionPane.WARNING_MESSAGE 464 ); 465 return; 466 } 467 url = url.replaceAll("#[^#]*$", ""); 468 OpenBrowser.displayUrl(url+"?action=edit"); 469 } 470 } 471 472 class ReloadAction extends AbstractAction { 473 public ReloadAction() { 474 putValue(SHORT_DESCRIPTION, tr("Reload the current help page")); 475 putValue(SMALL_ICON, ImageProvider.get("dialogs", "refresh")); 476 } 477 478 @Override 479 public void actionPerformed(ActionEvent e) { 480 openUrl(getUrl()); 481 } 482 } 483 484 static class BackAction extends AbstractAction implements Observer { 485 private HelpBrowserHistory history; 486 public BackAction(HelpBrowserHistory history) { 487 this.history = history; 488 history.addObserver(this); 489 putValue(SHORT_DESCRIPTION, tr("Go to the previous page")); 490 putValue(SMALL_ICON, ImageProvider.get("help", "previous")); 491 setEnabled(history.canGoBack()); 492 } 493 494 @Override 495 public void actionPerformed(ActionEvent e) { 496 history.back(); 497 } 498 @Override 499 public void update(Observable o, Object arg) { 500 setEnabled(history.canGoBack()); 501 } 502 } 503 504 static class ForwardAction extends AbstractAction implements Observer { 505 private HelpBrowserHistory history; 506 public ForwardAction(HelpBrowserHistory history) { 507 this.history = history; 508 history.addObserver(this); 509 putValue(SHORT_DESCRIPTION, tr("Go to the next page")); 510 putValue(SMALL_ICON, ImageProvider.get("help", "next")); 511 setEnabled(history.canGoForward()); 512 } 513 514 @Override 515 public void actionPerformed(ActionEvent e) { 516 history.forward(); 517 } 518 @Override 519 public void update(Observable o, Object arg) { 520 setEnabled(history.canGoForward()); 521 } 522 } 523 524 class HomeAction extends AbstractAction { 525 public HomeAction() { 526 putValue(SHORT_DESCRIPTION, tr("Go to the JOSM help home page")); 527 putValue(SMALL_ICON, ImageProvider.get("help", "home")); 528 } 529 530 @Override 531 public void actionPerformed(ActionEvent e) { 532 openHelpTopic("/"); 533 } 534 } 535 536 class HyperlinkHandler implements HyperlinkListener { 537 538 /** 539 * Scrolls the help browser to the element with id <code>id</code> 540 * 541 * @param id the id 542 * @return true, if an element with this id was found and scrolling was successful; false, otherwise 543 */ 544 protected boolean scrollToElementWithId(String id) { 545 Document d = help.getDocument(); 546 if (d instanceof HTMLDocument) { 547 HTMLDocument doc = (HTMLDocument) d; 548 Element element = doc.getElement(id); 549 try { 550 Rectangle r = help.modelToView(element.getStartOffset()); 551 if (r != null) { 552 Rectangle vis = help.getVisibleRect(); 553 r.height = vis.height; 554 help.scrollRectToVisible(r); 555 return true; 556 } 557 } catch (BadLocationException e) { 558 Main.warn(tr("Bad location in HTML document. Exception was: {0}", e.toString())); 559 Main.error(e); 560 } 561 } 562 return false; 563 } 564 565 /** 566 * Checks whether the hyperlink event originated on a <a ...> element with 567 * a relative href consisting of a URL fragment only, i.e. 568 * <a href="#thisIsALocalFragment">. If so, replies the fragment, i.e. 569 * "thisIsALocalFragment". 570 * 571 * Otherwise, replies <code>null</code> 572 * 573 * @param e the hyperlink event 574 * @return the local fragment or <code>null</code> 575 */ 576 protected String getUrlFragment(HyperlinkEvent e) { 577 AttributeSet set = e.getSourceElement().getAttributes(); 578 Object value = set.getAttribute(Tag.A); 579 if (!(value instanceof SimpleAttributeSet)) return null; 580 SimpleAttributeSet atts = (SimpleAttributeSet)value; 581 value = atts.getAttribute(javax.swing.text.html.HTML.Attribute.HREF); 582 if (value == null) return null; 583 String s = (String)value; 584 if (s.matches("#.*")) 585 return s.substring(1); 586 return null; 587 } 588 589 @Override 590 public void hyperlinkUpdate(HyperlinkEvent e) { 591 if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) 592 return; 593 if (e.getURL() == null || e.getURL().toString().startsWith(url+"#")) { 594 // Probably hyperlink event on a an A-element with a href consisting of 595 // a fragment only, i.e. "#ALocalFragment". 596 // 597 String fragment = getUrlFragment(e); 598 if (fragment != null) { 599 // first try to scroll to an element with id==fragment. This is the way 600 // table of contents are built in the JOSM wiki. If this fails, try to 601 // scroll to a <A name="..."> element. 602 // 603 if (!scrollToElementWithId(fragment)) { 604 help.scrollToReference(fragment); 605 } 606 } else { 607 HelpAwareOptionPane.showOptionDialog( 608 Main.parent, 609 tr("Failed to open help page. The target URL is empty."), 610 tr("Failed to open help page"), 611 JOptionPane.ERROR_MESSAGE, 612 null, /* no icon */ 613 null, /* standard options, just OK button */ 614 null, /* default is standard */ 615 null /* no help context */ 616 ); 617 } 618 } else if (e.getURL().toString().endsWith("action=edit")) { 619 OpenBrowser.displayUrl(e.getURL().toString()); 620 } else { 621 url = e.getURL().toString(); 622 openUrl(e.getURL().toString()); 623 } 624 } 625 } 626}