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); }); } }