From de1ae57da7c4ed3646ebd9c24cf3c8294a404adc Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Sun, 17 May 2026 18:06:31 +0200 Subject: [PATCH] added MD support --- src/main/java/cz/kamma/kfmanager/MainApp.java | 2 +- .../cz/kamma/kfmanager/ui/FileEditor.java | 358 +++++++++++++++++- 2 files changed, 353 insertions(+), 7 deletions(-) diff --git a/src/main/java/cz/kamma/kfmanager/MainApp.java b/src/main/java/cz/kamma/kfmanager/MainApp.java index 3decf5f..0266ea3 100644 --- a/src/main/java/cz/kamma/kfmanager/MainApp.java +++ b/src/main/java/cz/kamma/kfmanager/MainApp.java @@ -15,7 +15,7 @@ import java.io.InputStreamReader; */ public class MainApp { - public static final String APP_VERSION = "1.4.6"; + public static final String APP_VERSION = "1.4.7"; public enum OS { WINDOWS, LINUX, MACOS, UNKNOWN diff --git a/src/main/java/cz/kamma/kfmanager/ui/FileEditor.java b/src/main/java/cz/kamma/kfmanager/ui/FileEditor.java index cd0685f..771a215 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/FileEditor.java +++ b/src/main/java/cz/kamma/kfmanager/ui/FileEditor.java @@ -4,6 +4,8 @@ import cz.kamma.kfmanager.config.AppConfig; import cz.kamma.kfmanager.model.FileItem; import javax.swing.*; +import javax.swing.text.html.HTMLEditorKit; +import javax.swing.text.html.StyleSheet; import java.awt.*; import java.awt.event.KeyEvent; import java.awt.event.InputEvent; @@ -20,6 +22,7 @@ import java.util.regex.Matcher; */ public class FileEditor extends JFrame { private JTextArea textArea; + private JEditorPane markdownPane; private JScrollPane scrollPane; private File file; private String virtualPath; @@ -55,6 +58,8 @@ public class FileEditor extends JFrame { private JButton prevPageBtn = null; private JButton nextPageBtn = null; private JLabel pageOffsetLabel = null; + private boolean markdownMode = false; + private JCheckBoxMenuItem markdownItem = null; private javax.swing.undo.UndoManager undoManager; // Search support @@ -171,6 +176,9 @@ public class FileEditor extends JFrame { } private void showSearchPanel(boolean focusField) { + if (markdownMode) { + setMarkdownMode(false); + } searchPanel.setVisible(true); wholeWordCheckBox.setSelected(lastWholeWord); caseSensitiveCheckBox.setSelected(lastCaseSensitive); @@ -210,7 +218,7 @@ public class FileEditor extends JFrame { private void hideSearchPanel() { searchPanel.setVisible(false); - textArea.requestFocusInWindow(); + getActiveTextComponent().requestFocusInWindow(); revalidate(); } @@ -412,6 +420,17 @@ public class FileEditor extends JFrame { textArea.setColumns(120); } + markdownPane = new JEditorPane(); + markdownPane.setEditable(false); + markdownPane.setContentType("text/html"); + markdownPane.putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, Boolean.TRUE); + markdownPane.addHyperlinkListener(e -> { + if (e.getEventType() == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) { + openMarkdownLink(e); + } + }); + markdownPane.addCaretListener(e -> updateStatus()); + // Menu bar createMenuBar(); @@ -512,6 +531,15 @@ public class FileEditor extends JFrame { }); textArea.setComponentPopupMenu(popup); + + JPopupMenu markdownPopup = new JPopupMenu(); + JMenuItem markdownCopyItem = new JMenuItem(new javax.swing.text.DefaultEditorKit.CopyAction()); + markdownCopyItem.setText("Copy"); + markdownPopup.add(markdownCopyItem); + JMenuItem markdownSelectAllItem = new JMenuItem("Select All"); + markdownSelectAllItem.addActionListener(e -> markdownPane.selectAll()); + markdownPopup.add(markdownSelectAllItem); + markdownPane.setComponentPopupMenu(markdownPopup); } private void applyAppearance() { @@ -540,6 +568,14 @@ public class FileEditor extends JFrame { if (readOnly) { textArea.getCaret().setVisible(true); } + if (markdownPane != null) { + markdownPane.setFont(textArea.getFont()); + markdownPane.setBackground(textArea.getBackground()); + markdownPane.setForeground(textArea.getForeground()); + if (markdownMode) { + renderMarkdown(); + } + } } private void applyRecursiveColors(Container container, Color bg, boolean dark) { @@ -696,6 +732,12 @@ public class FileEditor extends JFrame { viewMenu.add(wrapItem); viewMenu.addSeparator(); + markdownItem = new JCheckBoxMenuItem("Markdown preview"); + markdownItem.setState(markdownMode); + markdownItem.setEnabled(readOnly && isMarkdownFile()); + markdownItem.addActionListener(e -> setMarkdownMode(markdownItem.getState())); + viewMenu.add(markdownItem); + JCheckBoxMenuItem hexItem = new JCheckBoxMenuItem("Hex view"); hexItem.setState(hexMode); hexItem.addActionListener(e -> { @@ -706,6 +748,262 @@ public class FileEditor extends JFrame { menuBar.add(viewMenu); } + private javax.swing.text.JTextComponent getActiveTextComponent() { + return markdownMode ? markdownPane : textArea; + } + + private boolean isMarkdownFile() { + String name = virtualPath != null ? virtualPath : file.getName(); + name = name.toLowerCase(); + return name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown") || name.endsWith(".mkd"); + } + + private void setMarkdownMode(boolean on) { + if (on && (!readOnly || !isMarkdownFile() || hexMode || isImageFile(file))) { + on = false; + } + + markdownMode = on; + if (markdownItem != null) { + markdownItem.setState(on); + markdownItem.setEnabled(readOnly && isMarkdownFile() && !hexMode && !isImageFile(file)); + } + + if (on) { + renderMarkdown(); + scrollPane.setViewportView(markdownPane); + SwingUtilities.invokeLater(() -> { + markdownPane.setCaretPosition(0); + markdownPane.requestFocusInWindow(); + }); + } else if (scrollPane != null && scrollPane.getViewport().getView() == markdownPane) { + scrollPane.setViewportView(textArea); + SwingUtilities.invokeLater(() -> textArea.requestFocusInWindow()); + } + updateStatus(); + } + + private void renderMarkdown() { + String raw = textArea.getText(); + markdownPane.setEditorKit(createMarkdownEditorKit()); + markdownPane.setText(markdownToHtml(raw)); + markdownPane.setCaretPosition(0); + } + + private void openMarkdownLink(javax.swing.event.HyperlinkEvent event) { + try { + if (event.getURL() != null) { + Desktop.getDesktop().browse(event.getURL().toURI()); + return; + } + String description = event.getDescription(); + if (description == null || description.isBlank()) return; + java.net.URI uri = new java.net.URI(description); + if (uri.isAbsolute()) { + Desktop.getDesktop().browse(uri); + return; + } + File baseDir = file.getParentFile(); + if (baseDir != null) { + Desktop.getDesktop().open(new File(baseDir, description)); + } + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Cannot open link:\n" + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + } + } + + private HTMLEditorKit createMarkdownEditorKit() { + HTMLEditorKit kit = new HTMLEditorKit(); + StyleSheet styles = kit.getStyleSheet(); + Font font = config.getEditorFont(); + Color bg = textArea.getBackground(); + Color fg = textArea.getForeground(); + String fontFamily = font != null ? font.getFamily() : "SansSerif"; + int fontSize = font != null ? font.getSize() : 13; + styles.addRule("body { font-family: " + cssString(fontFamily) + "; font-size: " + fontSize + "pt; " + + "background: " + toCssColor(bg) + "; color: " + toCssColor(fg) + "; margin: 12px; }"); + styles.addRule("h1 { font-size: 190%; margin: 0 0 10px 0; }"); + styles.addRule("h2 { font-size: 155%; margin: 18px 0 8px 0; }"); + styles.addRule("h3 { font-size: 130%; margin: 16px 0 7px 0; }"); + styles.addRule("h4, h5, h6 { margin: 14px 0 6px 0; }"); + styles.addRule("p { margin: 0 0 10px 0; }"); + styles.addRule("ul, ol { margin-top: 0; margin-bottom: 10px; }"); + styles.addRule("blockquote { margin: 0 0 10px 12px; padding-left: 10px; border-left: 3px solid #808080; }"); + styles.addRule("pre { font-family: Monospaced; font-size: " + fontSize + "pt; margin: 0 0 10px 0; padding: 8px; }"); + styles.addRule("code { font-family: Monospaced; }"); + styles.addRule("a { color: #2f7ed8; }"); + return kit; + } + + private String markdownToHtml(String markdown) { + StringBuilder body = new StringBuilder(); + String[] lines = markdown.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1); + StringBuilder paragraph = new StringBuilder(); + StringBuilder list = null; + String listTag = null; + boolean inFence = false; + StringBuilder fence = new StringBuilder(); + + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.startsWith("```")) { + if (inFence) { + body.append("
").append(escapeHtml(fence.toString())).append("
"); + fence.setLength(0); + inFence = false; + } else { + flushParagraph(body, paragraph); + list = flushList(body, list, listTag); + listTag = null; + inFence = true; + } + continue; + } + if (inFence) { + fence.append(line).append('\n'); + continue; + } + + if (trimmed.isEmpty()) { + flushParagraph(body, paragraph); + list = flushList(body, list, listTag); + listTag = null; + continue; + } + + String heading = headingHtml(trimmed); + if (heading != null) { + flushParagraph(body, paragraph); + list = flushList(body, list, listTag); + listTag = null; + body.append(heading); + continue; + } + + if (trimmed.matches("[-*_]{3,}")) { + flushParagraph(body, paragraph); + list = flushList(body, list, listTag); + listTag = null; + body.append("
"); + continue; + } + + Matcher unordered = Pattern.compile("^[-*+]\\s+(.+)$").matcher(trimmed); + Matcher ordered = Pattern.compile("^\\d+[.)]\\s+(.+)$").matcher(trimmed); + if (unordered.matches() || ordered.matches()) { + flushParagraph(body, paragraph); + String currentTag = unordered.matches() ? "ul" : "ol"; + if (list != null && !currentTag.equals(listTag)) { + list = flushList(body, list, listTag); + } + if (list == null) { + list = new StringBuilder(); + listTag = currentTag; + } + String item = unordered.matches() ? unordered.group(1) : ordered.group(1); + list.append("
  • ").append(renderInline(item)).append("
  • "); + continue; + } + + if (trimmed.startsWith(">")) { + flushParagraph(body, paragraph); + list = flushList(body, list, listTag); + listTag = null; + String quote = trimmed.replaceFirst("^>\\s?", ""); + body.append("
    ").append(renderInline(quote)).append("
    "); + continue; + } + + if (paragraph.length() > 0) { + paragraph.append(' '); + } + paragraph.append(trimmed); + } + + if (inFence) { + body.append("
    ").append(escapeHtml(fence.toString())).append("
    "); + } + flushParagraph(body, paragraph); + flushList(body, list, listTag); + return "" + body + ""; + } + + private void flushParagraph(StringBuilder body, StringBuilder paragraph) { + if (paragraph.length() == 0) return; + body.append("

    ").append(renderInline(paragraph.toString())).append("

    "); + paragraph.setLength(0); + } + + private StringBuilder flushList(StringBuilder body, StringBuilder list, String listTag) { + if (list == null || listTag == null) return null; + body.append('<').append(listTag).append('>').append(list).append("'); + return null; + } + + private String headingHtml(String trimmed) { + Matcher m = Pattern.compile("^(#{1,6})\\s+(.+)$").matcher(trimmed); + if (!m.matches()) return null; + int level = m.group(1).length(); + return "" + renderInline(m.group(2)) + ""; + } + + private String renderInline(String text) { + java.util.List codeSpans = new java.util.ArrayList<>(); + Matcher codeMatcher = Pattern.compile("`([^`]+)`").matcher(text); + StringBuffer protectedText = new StringBuffer(); + while (codeMatcher.find()) { + String token = "{{KF_MD_CODE_" + codeSpans.size() + "}}"; + codeSpans.add("" + escapeHtml(codeMatcher.group(1)) + ""); + codeMatcher.appendReplacement(protectedText, Matcher.quoteReplacement(token)); + } + codeMatcher.appendTail(protectedText); + + String html = escapeHtml(protectedText.toString()); + html = html.replaceAll("\\*\\*([^*]+)\\*\\*", "$1"); + html = html.replaceAll("__([^_]+)__", "$1"); + html = html.replaceAll("(?$1"); + html = html.replaceAll("(?$1"); + html = replaceLinks(html); + + for (int i = 0; i < codeSpans.size(); i++) { + html = html.replace("{{KF_MD_CODE_" + i + "}}", codeSpans.get(i)); + } + return html; + } + + private String replaceLinks(String html) { + Matcher m = Pattern.compile("\\[([^\\]]+)]\\(([^\\s)]+)\\)").matcher(html); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String label = m.group(1); + String href = m.group(2); + String replacement = "" + label + ""; + m.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + m.appendTail(sb); + return sb.toString(); + } + + private String escapeHtml(String text) { + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private String escapeAttribute(String text) { + return escapeHtml(text).replace("'", "'"); + } + + private String toCssColor(Color color) { + if (color == null) return "#ffffff"; + return "#%02x%02x%02x".formatted(color.getRed(), color.getGreen(), color.getBlue()); + } + + private String cssString(String value) { + return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"; + } + private void ensureHexControls() { if (hexControlPanel != null) return; hexControlPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); @@ -746,7 +1044,7 @@ public class FileEditor extends JFrame { showSearchPanel(false); } findNext(); - textArea.requestFocusInWindow(); + getActiveTextComponent().requestFocusInWindow(); }, KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), JComponent.WHEN_IN_FOCUSED_WINDOW); @@ -821,14 +1119,20 @@ public class FileEditor extends JFrame { rootPane.registerKeyboardAction(e -> { findPrevious(); - textArea.requestFocusInWindow(); + getActiveTextComponent().requestFocusInWindow(); }, KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_DOWN_MASK), JComponent.WHEN_IN_FOCUSED_WINDOW); } private void setHexMode(boolean on) { + if (on && markdownMode) { + setMarkdownMode(false); + } this.hexMode = on; + if (markdownItem != null) { + markdownItem.setEnabled(readOnly && isMarkdownFile() && !on && !isImageFile(file)); + } if (on) { // ensure bytes loaded try { @@ -1009,7 +1313,7 @@ public class FileEditor extends JFrame { if (virtualPath != null && ftpProfile == null) { try { fileBytes = cz.kamma.kfmanager.service.FileOperations.readFileFromArchive(file, virtualPath); - boolean binary = isBinary(fileBytes); + boolean binary = isBinaryForCurrentFile(fileBytes); if (binary && readOnly) { hexMode = true; buildHexViewText(0L); @@ -1029,6 +1333,7 @@ public class FileEditor extends JFrame { } if (undoManager != null) undoManager.discardAllEdits(); modified = false; + updateMarkdownPreviewAfterLoad(); updateTitle(); updateStatus(); return; @@ -1058,7 +1363,7 @@ public class FileEditor extends JFrame { } } - boolean binaryProbe = isBinary(probe); + boolean binaryProbe = isBinaryForCurrentFile(probe); if (binaryProbe && readOnly && size > maxFullLoadBytes) { // Open RAF and stream pages @@ -1077,7 +1382,7 @@ public class FileEditor extends JFrame { } else { // Small or text file: load fully fileBytes = readFileBytesWithChunking(file, size); - boolean binary = isBinary(fileBytes); + boolean binary = isBinaryForCurrentFile(fileBytes); if (binary && readOnly) { hexMode = true; buildHexViewText(0L); @@ -1098,6 +1403,7 @@ public class FileEditor extends JFrame { } if (undoManager != null) undoManager.discardAllEdits(); modified = false; + updateMarkdownPreviewAfterLoad(); updateTitle(); updateStatus(); } catch (IOException e) { @@ -1105,6 +1411,26 @@ public class FileEditor extends JFrame { } } + private void updateMarkdownPreviewAfterLoad() { + boolean shouldPreview = readOnly && isMarkdownFile() && !hexMode && !isImageFile(file); + setMarkdownMode(shouldPreview); + } + + private boolean isBinaryForCurrentFile(byte[] bytes) { + if (isMarkdownFile()) { + return containsNulByte(bytes); + } + return isBinary(bytes); + } + + private boolean containsNulByte(byte[] bytes) { + if (bytes == null) return false; + for (byte b : bytes) { + if (b == 0) return true; + } + return false; + } + private boolean isBinary(byte[] bytes) { if (bytes == null || bytes.length == 0) return false; int nonPrintable = 0; @@ -1499,6 +1825,26 @@ public class FileEditor extends JFrame { statusSelLabel.setText(" "); } + } else if (markdownMode) { + String text = textArea.getText(); + int chars = text != null ? text.length() : 0; + int bytes = 0; + try { + bytes = text != null ? text.getBytes("UTF-8").length : 0; + } catch (Exception ignore) {} + statusPosLabel.setText("Markdown preview | %d chars / %d bytes".formatted(chars, bytes)); + + String selected = markdownPane.getSelectedText(); + int selChars = selected != null ? selected.length() : 0; + if (selChars > 0) { + int selBytes = 0; + try { + selBytes = selected.getBytes("UTF-8").length; + } catch (Exception ignore) {} + statusSelLabel.setText("Selected: %d chars / %d bytes".formatted(selChars, selBytes)); + } else { + statusSelLabel.setText(" "); + } } else { int caret = textArea.getCaretPosition(); String text = textArea.getText();