summaryrefslogtreecommitdiff
path: root/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java
diff options
context:
space:
mode:
Diffstat (limited to 'base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java')
-rw-r--r--base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java374
1 files changed, 374 insertions, 0 deletions
diff --git a/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java b/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java
new file mode 100644
index 0000000..3a1c72e
--- /dev/null
+++ b/base/src/main/java/bjc/utils/gui/MarkdownLinkSupport.java
@@ -0,0 +1,374 @@
+package bjc.utils.gui;
+
+import javax.swing.*;
+import javax.swing.text.*;
+import java.awt.*;
+import java.awt.event.*;
+import java.net.URI;
+
+public final class MarkdownLinkSupport {
+
+ @FunctionalInterface
+ public interface LinkHandler {
+ void onLinkActivated(String href);
+ }
+
+ /**
+ * Provides glossary behavior for glossary:// links.
+ *
+ * The "key" is whatever you decide (term ID, slug, etc.) extracted from the
+ * URI.
+ */
+ public interface GlossaryProvider {
+ /**
+ * Return a user-facing definition for tooltip display, or null if unknown. You
+ * can return plain text; it will be wrapped/escaped into HTML.
+ */
+ String getDefinition(String key);
+
+ /**
+ * Called when user clicks a glossary:// link.
+ */
+ void openEntry(String key);
+ }
+
+ private MarkdownLinkSupport() {
+ }
+
+ // ---------------------------------------------------------------------
+ // Existing behavior (browser links only)
+ // ---------------------------------------------------------------------
+
+ public static void install(JTextPane pane) {
+ install(pane, MarkdownLinkSupport::openInBrowserBestEffort, null, "glossary");
+ }
+
+ public static void install(JTextPane pane, LinkHandler handler) {
+ install(pane, handler, null, "glossary");
+ }
+
+ // ---------------------------------------------------------------------
+ // New behavior (browser links + glossary links)
+ // ---------------------------------------------------------------------
+
+ /**
+ * Installs link hover/click behavior.
+ *
+ * @param pane the JTextPane containing styled text
+ * @param defaultHandler handler for normal links (http, https, mailto, etc.)
+ * @param glossary optional glossary provider for glossary-scheme links
+ * @param glossaryScheme scheme name (e.g. "glossary" => glossary://term)
+ */
+ public static void install(JTextPane pane, LinkHandler defaultHandler, GlossaryProvider glossary,
+ String glossaryScheme) {
+ if (pane == null)
+ throw new IllegalArgumentException("pane == null");
+ if (defaultHandler == null)
+ throw new IllegalArgumentException("defaultHandler == null");
+ if (glossaryScheme == null || glossaryScheme.trim().isEmpty())
+ glossaryScheme = "glossary";
+
+ final Cursor defaultCursor = pane.getCursor();
+ final Cursor handCursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR);
+
+ final String schemeLower = glossaryScheme.toLowerCase();
+
+ // Simple hover caching so we don't re-set tooltip/cursor every pixel of
+ // movement
+ final HoverState hoverState = new HoverState();
+
+ MouseMotionListener motion = new MouseMotionAdapter() {
+ @Override
+ public void mouseMoved(MouseEvent e) {
+ String href = hrefAtPoint(pane, e.getPoint());
+
+ if (href != null && href.equals(hoverState.lastHref)) {
+ return; // no change
+ }
+ hoverState.lastHref = href;
+
+ if (href == null) {
+ resetHover(pane, defaultCursor);
+ return;
+ }
+
+ if (pane.getCursor() != handCursor)
+ pane.setCursor(handCursor);
+
+ // Tooltip decision
+ String tip = tooltipForHref(href, glossary, schemeLower);
+ pane.setToolTipText(tip);
+ }
+ };
+
+ MouseListener click = new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (!SwingUtilities.isLeftMouseButton(e))
+ return;
+ if (e.getClickCount() != 1)
+ return;
+
+ String href = hrefAtPoint(pane, e.getPoint());
+ if (href == null)
+ return;
+
+ if (isScheme(href, schemeLower) && glossary != null) {
+ String key = extractGlossaryKey(href, schemeLower);
+ if (key != null && !key.isEmpty()) {
+ glossary.openEntry(key);
+ e.consume();
+ return;
+ }
+ }
+
+ // default
+ defaultHandler.onLinkActivated(href);
+ e.consume();
+ }
+
+ @Override
+ public void mouseExited(MouseEvent e) {
+ hoverState.lastHref = null;
+ resetHover(pane, defaultCursor);
+ }
+ };
+
+ pane.addMouseMotionListener(motion);
+ pane.addMouseListener(click);
+ }
+
+ private static void resetHover(JTextPane pane, Cursor defaultCursor) {
+ if (pane.getCursor() != defaultCursor)
+ pane.setCursor(defaultCursor);
+ pane.setToolTipText(null);
+ }
+
+ private static String tooltipForHref(String href, GlossaryProvider glossary, String glossarySchemeLower) {
+ if (isScheme(href, glossarySchemeLower) && glossary != null) {
+ String key = extractGlossaryKey(href, glossarySchemeLower);
+ if (key == null || key.isEmpty())
+ return null;
+
+ String def = glossary.getDefinition(key);
+ if (def == null || def.trim().isEmpty()) {
+ // Fallback tooltip if you want: show the key
+ return htmlTooltip("Glossary: " + key);
+ }
+ return htmlTooltip(def);
+ }
+
+ // Default behavior: show the href
+ return href;
+ }
+
+ private static boolean isScheme(String href, String schemeLower) {
+ try {
+ URI uri = new URI(href);
+ String scheme = uri.getScheme();
+ return scheme != null && schemeLower.equalsIgnoreCase(scheme);
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ /**
+ * Extract your glossary “key” from a glossary:// URI.
+ *
+ * Accepts: glossary://TERM glossary://TERM/some/subpath glossary:TERM (also
+ * works, if you ever emit that)
+ *
+ * Returns something stable to use as lookup key.
+ */
+ private static String extractGlossaryKey(String href, String schemeLower) {
+ try {
+ URI uri = new URI(href);
+ String scheme = uri.getScheme();
+ if (scheme == null || !schemeLower.equalsIgnoreCase(scheme))
+ return null;
+
+ // Preferred: glossary://host/path
+ String host = uri.getHost();
+ String path = uri.getPath();
+
+ if (host != null && !host.isEmpty()) {
+ if (path != null && !path.isEmpty() && !"/".equals(path)) {
+ return host + path; // e.g. TERM/sub
+ }
+ return host; // TERM
+ }
+
+ // Fallback: glossary:TERM or other scheme-specific forms
+ String ssp = uri.getSchemeSpecificPart();
+ if (ssp == null)
+ return null;
+
+ // strip leading // if present
+ if (ssp.startsWith("//"))
+ ssp = ssp.substring(2);
+
+ // strip query/fragment if someone includes them
+ int q = ssp.indexOf('?');
+ if (q >= 0)
+ ssp = ssp.substring(0, q);
+ int f = ssp.indexOf('#');
+ if (f >= 0)
+ ssp = ssp.substring(0, f);
+
+ return ssp.trim();
+ } catch (Exception ignored) {
+ // If href isn't a valid URI, you could still support a raw prefix check:
+ String prefix = schemeLower + "://";
+ if (href != null && href.toLowerCase().startsWith(prefix)) {
+ return href.substring(prefix.length()).trim();
+ }
+ return null;
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Link attribute lookup in the StyledDocument
+ // ---------------------------------------------------------------------
+
+ private static String hrefAtPoint(JTextPane pane, Point p) {
+ Document doc = pane.getDocument();
+ if (!(doc instanceof StyledDocument))
+ return null;
+
+ int pos = viewToModelCompat(pane, p);
+ if (pos < 0 || pos >= doc.getLength())
+ return null;
+
+ return findHrefAtOffset((StyledDocument) doc, pos);
+ }
+
+ private static int viewToModelCompat(JTextPane pane, Point p) {
+ // Java 8 friendly
+ try {
+ return pane.viewToModel(p);
+ } catch (Exception ignored) {
+ return -1;
+ }
+ }
+
+ private static String findHrefAtOffset(StyledDocument doc, int offset) {
+ Element charElem = doc.getCharacterElement(offset);
+ String href = getHrefFromAttributes(charElem.getAttributes());
+ if (href != null)
+ return href;
+
+ // Sometimes attributes land on parent elements
+ Element parent = charElem.getParentElement();
+ while (parent != null) {
+ href = getHrefFromAttributes(parent.getAttributes());
+ if (href != null)
+ return href;
+ parent = parent.getParentElement();
+ }
+ return null;
+ }
+
+ private static String getHrefFromAttributes(AttributeSet attrs) {
+ Object v = attrs.getAttribute(MarkdownEditorKit.MarkdownDocument.ATTR_LINK_HREF);
+ return (v instanceof String) ? (String) v : null;
+ }
+
+ // ---------------------------------------------------------------------
+ // Default “open in browser” handler
+ // ---------------------------------------------------------------------
+
+ private static void openInBrowserBestEffort(String href) {
+ if (href == null || href.trim().isEmpty())
+ return;
+
+ if (tryBrowse(href))
+ return;
+
+ // If missing scheme, try https://
+ if (!href.contains("://") && !href.startsWith("mailto:")) {
+ tryBrowse("https://" + href);
+ }
+ }
+
+ private static boolean tryBrowse(String href) {
+ try {
+ if (!Desktop.isDesktopSupported())
+ return false;
+ Desktop d = Desktop.getDesktop();
+ if (!d.isSupported(Desktop.Action.BROWSE))
+ return false;
+
+ d.browse(new URI(href));
+ return true;
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Tooltip formatting
+ // ---------------------------------------------------------------------
+
+ /**
+ * Swing tooltips support HTML. This wraps and escapes your glossary text. You
+ * can tune the width (e.g. 320px) to make definitions readable.
+ */
+ private static String htmlTooltip(String text) {
+ String escaped = escapeHtml(text);
+ // Convert newlines to <br> for multi-line definitions
+ escaped = escaped.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>");
+ return "<html><div style='width:320px;'>" + escaped + "</div></html>";
+ }
+
+ private static String escapeHtml(String s) {
+ if (s == null)
+ return "";
+ StringBuilder sb = new StringBuilder(s.length() + 32);
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '&':
+ sb.append("&amp;");
+ break;
+ case '<':
+ sb.append("&lt;");
+ break;
+ case '>':
+ sb.append("&gt;");
+ break;
+ case '"':
+ sb.append("&quot;");
+ break;
+ case '\'':
+ sb.append("&#39;");
+ break;
+ default:
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static final class HoverState {
+ String lastHref;
+ }
+}
+
+/*
+ * JTextPane pane = new JTextPane(); pane.setEditorKit(new MarkdownEditorKit());
+ * ((MarkdownEditorKit.MarkdownDocument)pane.getDocument()).setMarkdown(markdown
+ * );
+ *
+ * // Plug in your glossary: MarkdownLinkSupport.install( pane, href ->
+ * System.out.println("normal link clicked: " + href), // or open in browser new
+ * MarkdownLinkSupport.GlossaryProvider() {
+ *
+ * @Override public String getDefinition(String key) { // Look up from your
+ * glossary map/DB if ("Stalk".equalsIgnoreCase(key)) { return
+ * "Stalk: At the beginning of your end step, if an opponent was dealt damage..."
+ * ; } return null; }
+ *
+ * @Override public void openEntry(String key) { // Navigate in your app (select
+ * glossary panel, jump to entry, etc.)
+ * System.out.println("open glossary entry: " + key); } }, "glossary" );
+ */ \ No newline at end of file