diff options
Diffstat (limited to 'base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java')
| -rw-r--r-- | base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java | 457 |
1 files changed, 457 insertions, 0 deletions
diff --git a/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java b/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java new file mode 100644 index 0000000..a06ede0 --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java @@ -0,0 +1,457 @@ +package bjc.utils.gui;
+
+import javax.swing.*;
+import javax.swing.event.CaretEvent;
+import javax.swing.event.CaretListener;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import javax.swing.text.*;
+import java.awt.*;
+import java.awt.event.*;
+import java.net.URI;
+
+public final class MarkdownSplitPreview extends JPanel {
+
+ private static final int RENDER_DEBOUNCE_MS = 150;
+ private static final int CARET_SYNC_DEBOUNCE_MS = 75;
+
+ // If true: when the user scrolls the preview, automatically turn off "Follow
+ // caret".
+ private static final boolean AUTO_DISABLE_FOLLOW_ON_PREVIEW_SCROLL = true;
+
+ private final JTextArea editor = new JTextArea();
+ private final JTextPane preview = new JTextPane();
+
+ private final MarkdownEditorKit previewKit = new MarkdownEditorKit();
+
+ private final Timer renderTimer;
+ private final Timer caretSyncTimer;
+
+ private String lastRenderedMarkdown = null;
+
+ // Reentrancy guard for programmatic scrollbar updates
+ private boolean adjustingScrollbars = false;
+
+ // Follow-caret UI state
+ private final JToggleButton followCaretToggle = new JToggleButton("Follow caret", true);
+
+ public MarkdownSplitPreview() {
+ super(new BorderLayout());
+
+ // Toolbar
+ JToolBar tb = new JToolBar();
+ tb.setFloatable(false);
+
+ followCaretToggle.setFocusable(false);
+ followCaretToggle.setToolTipText("When enabled, the preview tracks where you are editing.");
+ tb.add(followCaretToggle);
+
+ JButton renderNowButton = new JButton("Render now");
+ renderNowButton.setFocusable(false);
+ renderNowButton.setToolTipText("Force an immediate preview update (Ctrl+Enter / Ctrl+R).");
+ tb.addSeparator();
+ tb.add(renderNowButton);
+
+ add(tb, BorderLayout.NORTH);
+
+ // Editor (left)
+ editor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13));
+ editor.setTabSize(4);
+ editor.setLineWrap(true);
+ editor.setWrapStyleWord(true);
+
+ // Preview (right)
+ preview.setEditable(false);
+ preview.setEditorKit(previewKit);
+
+ final JScrollPane leftScroll = new JScrollPane(editor);
+ final JScrollPane rightScroll = new JScrollPane(preview);
+
+ JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftScroll, rightScroll);
+ split.setResizeWeight(0.5);
+ add(split, BorderLayout.CENTER);
+
+ // Debounced render (EDT)
+ renderTimer = new Timer(RENDER_DEBOUNCE_MS, e -> renderPreview(rightScroll, leftScroll));
+ renderTimer.setRepeats(false);
+
+ // Debounced caret-follow (EDT)
+ caretSyncTimer = new Timer(CARET_SYNC_DEBOUNCE_MS, e -> maybeSyncPreviewToCaret(leftScroll, rightScroll));
+ caretSyncTimer.setRepeats(false);
+
+ // Typing/editing triggers render + (optional) caret-follow
+ editor.getDocument().addDocumentListener(new DocumentListener() {
+ @Override
+ public void insertUpdate(DocumentEvent e) {
+ schedule();
+ }
+
+ @Override
+ public void removeUpdate(DocumentEvent e) {
+ schedule();
+ }
+
+ @Override
+ public void changedUpdate(DocumentEvent e) {
+ schedule();
+ }
+
+ private void schedule() {
+ renderTimer.restart();
+ caretSyncTimer.restart();
+ }
+ });
+
+ // Caret movement should also move preview (if follow enabled)
+ editor.addCaretListener(new CaretListener() {
+ @Override
+ public void caretUpdate(CaretEvent e) {
+ if (!editor.isFocusOwner())
+ return;
+ caretSyncTimer.restart();
+ }
+ });
+
+ // Button triggers immediate render
+ renderNowButton.addActionListener(e -> renderNow(rightScroll, leftScroll));
+
+ installRenderNowHotkeys(rightScroll, leftScroll);
+ installFollowCaretHotkey(); // Ctrl+L
+ installLinkHandling(preview);
+ installScrollSync(leftScroll, rightScroll);
+ installPreviewManualScrollDetection(rightScroll);
+
+ // Initial render
+ renderPreview(rightScroll, leftScroll);
+ }
+
+ public void setMarkdown(String markdown) {
+ editor.setText(markdown == null ? "" : markdown);
+ renderTimer.stop();
+ caretSyncTimer.stop();
+ JScrollPane rightScroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, preview);
+ JScrollPane leftScroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, editor);
+ if (rightScroll != null && leftScroll != null) {
+ renderPreview(rightScroll, leftScroll);
+ } else {
+ previewKit.setMarkdown(preview, markdown == null ? "" : markdown);
+ lastRenderedMarkdown = markdown;
+ }
+ }
+
+ public String getMarkdown() {
+ return editor.getText();
+ }
+
+ public boolean isFollowCaretEnabled() {
+ return followCaretToggle.isSelected();
+ }
+
+ public void setFollowCaretEnabled(boolean enabled) {
+ followCaretToggle.setSelected(enabled);
+ }
+
+ // -------------------------------------------------------------------------
+ // Render logic
+ // -------------------------------------------------------------------------
+
+ private void renderPreview(JScrollPane previewScroll, JScrollPane editorScroll) {
+ String md = editor.getText();
+ if (md == null)
+ md = "";
+
+ // Skip rerender if unchanged
+ if (md.equals(lastRenderedMarkdown)) {
+ maybeSyncPreviewToCaret(editorScroll, previewScroll);
+ return;
+ }
+
+ lastRenderedMarkdown = md;
+
+ try {
+ previewKit.setMarkdown(preview, md);
+ } catch (RuntimeException ex) {
+ preview.setDocument(new DefaultStyledDocument());
+ preview.setText("Markdown render error:\n" + ex.getMessage());
+ }
+
+ SwingUtilities.invokeLater(() -> maybeSyncPreviewToCaret(editorScroll, previewScroll));
+ }
+
+ private void renderNow(JScrollPane previewScroll, JScrollPane editorScroll) {
+ renderTimer.stop();
+ renderPreview(previewScroll, editorScroll);
+ }
+
+ // -------------------------------------------------------------------------
+ // Hotkeys
+ // -------------------------------------------------------------------------
+
+ private void installRenderNowHotkeys(JScrollPane previewScroll, JScrollPane editorScroll) {
+ InputMap im = editor.getInputMap(JComponent.WHEN_FOCUSED);
+ ActionMap am = editor.getActionMap();
+
+ Action renderNowAction = new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ renderNow(previewScroll, editorScroll);
+ }
+ };
+
+ // Ctrl+Enter
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK), "md.renderNow");
+ // Ctrl+R
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_R, InputEvent.CTRL_DOWN_MASK), "md.renderNow");
+
+ am.put("md.renderNow", renderNowAction);
+ }
+
+ private void installFollowCaretHotkey() {
+ InputMap im = editor.getInputMap(JComponent.WHEN_FOCUSED);
+ ActionMap am = editor.getActionMap();
+
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_L, InputEvent.CTRL_DOWN_MASK), "md.toggleFollowCaret");
+ am.put("md.toggleFollowCaret", new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ followCaretToggle.setSelected(!followCaretToggle.isSelected());
+ }
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Clickable links in preview
+ // -------------------------------------------------------------------------
+
+ private void installLinkHandling(JTextPane pane) {
+ pane.addMouseMotionListener(new MouseMotionAdapter() {
+ @Override
+ public void mouseMoved(MouseEvent e) {
+ String href = hrefAtPoint(pane, e.getPoint());
+ pane.setCursor(
+ href != null ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : Cursor.getDefaultCursor());
+ }
+ });
+
+ pane.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (e.getButton() != MouseEvent.BUTTON1 || e.getClickCount() != 1)
+ return;
+ String href = hrefAtPoint(pane, e.getPoint());
+ if (href != null)
+ openLink(href, pane);
+ }
+ });
+ }
+
+ private static String hrefAtPoint(JTextPane pane, Point p) {
+ int pos = pane.viewToModel(p); // Java 8 compatible
+ if (pos < 0)
+ return null;
+
+ Document d = pane.getDocument();
+ if (!(d instanceof StyledDocument))
+ return null;
+
+ StyledDocument sd = (StyledDocument) d;
+ Element elem = sd.getCharacterElement(pos);
+ if (elem == null)
+ return null;
+
+ AttributeSet attrs = elem.getAttributes();
+ Object href = attrs.getAttribute(MarkdownEditorKit.MarkdownDocument.ATTR_LINK_HREF);
+ return (href instanceof String) ? (String) href : null;
+ }
+
+ private static void openLink(String href, Component parent) {
+ try {
+ if (!Desktop.isDesktopSupported()) {
+ Toolkit.getDefaultToolkit().beep();
+ return;
+ }
+ Desktop.getDesktop().browse(new URI(href));
+ } catch (Exception ex) {
+ JOptionPane.showMessageDialog(parent, "Could not open link:\n" + href + "\n\n" + ex.getMessage(),
+ "Open Link Failed", JOptionPane.ERROR_MESSAGE);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Manual preview scroll detection (optionally disables follow-caret)
+ // -------------------------------------------------------------------------
+
+ private void installPreviewManualScrollDetection(JScrollPane previewScroll) {
+ JScrollBar bar = previewScroll.getVerticalScrollBar();
+
+ // Mouse wheel in preview => manual
+ MouseWheelListener wheel = e -> onPreviewManualScroll();
+ previewScroll.addMouseWheelListener(wheel);
+ previewScroll.getViewport().addMouseWheelListener(wheel);
+
+ // Scrollbar drag/click => manual
+ bar.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mousePressed(MouseEvent e) {
+ onPreviewManualScroll();
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ onPreviewManualScroll();
+ }
+ });
+
+ // Any user adjustment (not programmatic) => manual
+ bar.addAdjustmentListener(e -> {
+ if (adjustingScrollbars)
+ return;
+ if (e.getValueIsAdjusting())
+ onPreviewManualScroll();
+ });
+ }
+
+ private void onPreviewManualScroll() {
+ if (AUTO_DISABLE_FOLLOW_ON_PREVIEW_SCROLL && followCaretToggle.isSelected()) {
+ followCaretToggle.setSelected(false);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Scroll sync (proportional, bidirectional)
+ // -------------------------------------------------------------------------
+
+ private void installScrollSync(JScrollPane editorScroll, JScrollPane previewScroll) {
+ JScrollBar eBar = editorScroll.getVerticalScrollBar();
+ JScrollBar pBar = previewScroll.getVerticalScrollBar();
+
+ // Editor scrolled => move preview only if follow is enabled
+ eBar.addAdjustmentListener(ev -> {
+ if (adjustingScrollbars)
+ return;
+ if (!followCaretToggle.isSelected())
+ return;
+ syncFromTo(eBar, pBar);
+ });
+
+ // Preview scrolled => move editor always (user intent)
+ pBar.addAdjustmentListener(ev -> {
+ if (adjustingScrollbars)
+ return;
+ syncFromTo(pBar, eBar);
+ });
+ }
+
+ private void syncFromTo(JScrollBar from, JScrollBar to) {
+ int fromRange = scrollRange(from);
+ int toRange = scrollRange(to);
+ if (fromRange <= 0 || toRange <= 0)
+ return;
+
+ double frac = from.getValue() / (double) fromRange;
+ int target = (int) Math.round(frac * toRange);
+ target = clamp(target, 0, toRange);
+
+ if (to.getValue() == target)
+ return;
+
+ adjustingScrollbars = true;
+ try {
+ to.setValue(target);
+ } finally {
+ adjustingScrollbars = false;
+ }
+ }
+
+ private static int scrollRange(JScrollBar bar) {
+ int range = bar.getMaximum() - bar.getVisibleAmount();
+ return Math.max(0, range);
+ }
+
+ private static int clamp(int v, int lo, int hi) {
+ if (v < lo)
+ return lo;
+ if (v > hi)
+ return hi;
+ return v;
+ }
+
+ // -------------------------------------------------------------------------
+ // Caret-follow preview syncing (when enabled)
+ // -------------------------------------------------------------------------
+
+ private void maybeSyncPreviewToCaret(JScrollPane editorScroll, JScrollPane previewScroll) {
+ if (!followCaretToggle.isSelected())
+ return;
+ if (!editor.isFocusOwner())
+ return;
+
+ syncPreviewToCaret(editorScroll, previewScroll);
+ }
+
+ private void syncPreviewToCaret(JScrollPane editorScroll, JScrollPane previewScroll) {
+ JScrollBar pBar = previewScroll.getVerticalScrollBar();
+ int pRange = scrollRange(pBar);
+ if (pRange <= 0)
+ return;
+
+ int caret = editor.getCaretPosition();
+ Rectangle caretRect;
+ try {
+ caretRect = editor.modelToView(caret); // Java 8
+ } catch (BadLocationException e) {
+ return;
+ }
+ if (caretRect == null)
+ return;
+
+ int prefHeight = editor.getPreferredSize().height;
+ int visibleHeight = editorScroll.getViewport().getExtentSize().height;
+
+ int eRange = Math.max(0, prefHeight - visibleHeight);
+ if (eRange <= 0)
+ return;
+
+ double frac = caretRect.y / (double) eRange;
+ if (frac < 0)
+ frac = 0;
+ if (frac > 1)
+ frac = 1;
+
+ int target = (int) Math.round(frac * pRange);
+ target = clamp(target, 0, pRange);
+
+ if (pBar.getValue() == target)
+ return;
+
+ adjustingScrollbars = true;
+ try {
+ pBar.setValue(target);
+ } finally {
+ adjustingScrollbars = false;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Demo
+ // -------------------------------------------------------------------------
+
+ public static void main(String[] args) {
+ SwingUtilities.invokeLater(() -> {
+ JFrame f = new JFrame("Markdown Editor + Preview");
+ f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
+
+ MarkdownSplitPreview panel = new MarkdownSplitPreview();
+ panel.setMarkdown("# Hello\n\n" + "Type Markdown on the left.\n\n"
+ + "A link: [example](https://example.com)\n\n" + "| Col A | Col B |\n" + "|------:|:-----|\n"
+ + "| **bold** | `code` |\n" + "| [link](https://example.com) | *italic* |\n\n"
+ + "If you scroll the preview, Follow caret will turn off.\n"
+ + "Toggle it back on with the button or Ctrl+L.\n");
+
+ f.setContentPane(panel);
+ f.setSize(1100, 700);
+ f.setLocationRelativeTo(null);
+ f.setVisible(true);
+ });
+ }
+}
|
