added MD support

This commit is contained in:
Radek Davidek 2026-05-17 18:06:31 +02:00
parent a4dedd1324
commit de1ae57da7
2 changed files with 353 additions and 7 deletions

View File

@ -15,7 +15,7 @@ import java.io.InputStreamReader;
*/ */
public class MainApp { public class MainApp {
public static final String APP_VERSION = "1.4.6"; public static final String APP_VERSION = "1.4.7";
public enum OS { public enum OS {
WINDOWS, LINUX, MACOS, UNKNOWN WINDOWS, LINUX, MACOS, UNKNOWN

View File

@ -4,6 +4,8 @@ import cz.kamma.kfmanager.config.AppConfig;
import cz.kamma.kfmanager.model.FileItem; import cz.kamma.kfmanager.model.FileItem;
import javax.swing.*; import javax.swing.*;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.*; import java.awt.*;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.awt.event.InputEvent; import java.awt.event.InputEvent;
@ -20,6 +22,7 @@ import java.util.regex.Matcher;
*/ */
public class FileEditor extends JFrame { public class FileEditor extends JFrame {
private JTextArea textArea; private JTextArea textArea;
private JEditorPane markdownPane;
private JScrollPane scrollPane; private JScrollPane scrollPane;
private File file; private File file;
private String virtualPath; private String virtualPath;
@ -55,6 +58,8 @@ public class FileEditor extends JFrame {
private JButton prevPageBtn = null; private JButton prevPageBtn = null;
private JButton nextPageBtn = null; private JButton nextPageBtn = null;
private JLabel pageOffsetLabel = null; private JLabel pageOffsetLabel = null;
private boolean markdownMode = false;
private JCheckBoxMenuItem markdownItem = null;
private javax.swing.undo.UndoManager undoManager; private javax.swing.undo.UndoManager undoManager;
// Search support // Search support
@ -171,6 +176,9 @@ public class FileEditor extends JFrame {
} }
private void showSearchPanel(boolean focusField) { private void showSearchPanel(boolean focusField) {
if (markdownMode) {
setMarkdownMode(false);
}
searchPanel.setVisible(true); searchPanel.setVisible(true);
wholeWordCheckBox.setSelected(lastWholeWord); wholeWordCheckBox.setSelected(lastWholeWord);
caseSensitiveCheckBox.setSelected(lastCaseSensitive); caseSensitiveCheckBox.setSelected(lastCaseSensitive);
@ -210,7 +218,7 @@ public class FileEditor extends JFrame {
private void hideSearchPanel() { private void hideSearchPanel() {
searchPanel.setVisible(false); searchPanel.setVisible(false);
textArea.requestFocusInWindow(); getActiveTextComponent().requestFocusInWindow();
revalidate(); revalidate();
} }
@ -412,6 +420,17 @@ public class FileEditor extends JFrame {
textArea.setColumns(120); 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 // Menu bar
createMenuBar(); createMenuBar();
@ -512,6 +531,15 @@ public class FileEditor extends JFrame {
}); });
textArea.setComponentPopupMenu(popup); 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() { private void applyAppearance() {
@ -540,6 +568,14 @@ public class FileEditor extends JFrame {
if (readOnly) { if (readOnly) {
textArea.getCaret().setVisible(true); 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) { private void applyRecursiveColors(Container container, Color bg, boolean dark) {
@ -696,6 +732,12 @@ public class FileEditor extends JFrame {
viewMenu.add(wrapItem); viewMenu.add(wrapItem);
viewMenu.addSeparator(); 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"); JCheckBoxMenuItem hexItem = new JCheckBoxMenuItem("Hex view");
hexItem.setState(hexMode); hexItem.setState(hexMode);
hexItem.addActionListener(e -> { hexItem.addActionListener(e -> {
@ -706,6 +748,262 @@ public class FileEditor extends JFrame {
menuBar.add(viewMenu); 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("<pre><code>").append(escapeHtml(fence.toString())).append("</code></pre>");
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("<hr>");
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("<li>").append(renderInline(item)).append("</li>");
continue;
}
if (trimmed.startsWith(">")) {
flushParagraph(body, paragraph);
list = flushList(body, list, listTag);
listTag = null;
String quote = trimmed.replaceFirst("^>\\s?", "");
body.append("<blockquote>").append(renderInline(quote)).append("</blockquote>");
continue;
}
if (paragraph.length() > 0) {
paragraph.append(' ');
}
paragraph.append(trimmed);
}
if (inFence) {
body.append("<pre><code>").append(escapeHtml(fence.toString())).append("</code></pre>");
}
flushParagraph(body, paragraph);
flushList(body, list, listTag);
return "<html><body>" + body + "</body></html>";
}
private void flushParagraph(StringBuilder body, StringBuilder paragraph) {
if (paragraph.length() == 0) return;
body.append("<p>").append(renderInline(paragraph.toString())).append("</p>");
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("</").append(listTag).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 "<h" + level + ">" + renderInline(m.group(2)) + "</h" + level + ">";
}
private String renderInline(String text) {
java.util.List<String> 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("<code>" + escapeHtml(codeMatcher.group(1)) + "</code>");
codeMatcher.appendReplacement(protectedText, Matcher.quoteReplacement(token));
}
codeMatcher.appendTail(protectedText);
String html = escapeHtml(protectedText.toString());
html = html.replaceAll("\\*\\*([^*]+)\\*\\*", "<strong>$1</strong>");
html = html.replaceAll("__([^_]+)__", "<strong>$1</strong>");
html = html.replaceAll("(?<!\\*)\\*([^*]+)\\*(?!\\*)", "<em>$1</em>");
html = html.replaceAll("(?<!_)_([^_]+)_(?!_)", "<em>$1</em>");
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 = "<a href=\"" + escapeAttribute(href) + "\">" + label + "</a>";
m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
}
m.appendTail(sb);
return sb.toString();
}
private String escapeHtml(String text) {
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
private String escapeAttribute(String text) {
return escapeHtml(text).replace("'", "&#39;");
}
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() { private void ensureHexControls() {
if (hexControlPanel != null) return; if (hexControlPanel != null) return;
hexControlPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); hexControlPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
@ -746,7 +1044,7 @@ public class FileEditor extends JFrame {
showSearchPanel(false); showSearchPanel(false);
} }
findNext(); findNext();
textArea.requestFocusInWindow(); getActiveTextComponent().requestFocusInWindow();
}, },
KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW); JComponent.WHEN_IN_FOCUSED_WINDOW);
@ -821,14 +1119,20 @@ public class FileEditor extends JFrame {
rootPane.registerKeyboardAction(e -> { rootPane.registerKeyboardAction(e -> {
findPrevious(); findPrevious();
textArea.requestFocusInWindow(); getActiveTextComponent().requestFocusInWindow();
}, },
KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_DOWN_MASK), KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW); JComponent.WHEN_IN_FOCUSED_WINDOW);
} }
private void setHexMode(boolean on) { private void setHexMode(boolean on) {
if (on && markdownMode) {
setMarkdownMode(false);
}
this.hexMode = on; this.hexMode = on;
if (markdownItem != null) {
markdownItem.setEnabled(readOnly && isMarkdownFile() && !on && !isImageFile(file));
}
if (on) { if (on) {
// ensure bytes loaded // ensure bytes loaded
try { try {
@ -1009,7 +1313,7 @@ public class FileEditor extends JFrame {
if (virtualPath != null && ftpProfile == null) { if (virtualPath != null && ftpProfile == null) {
try { try {
fileBytes = cz.kamma.kfmanager.service.FileOperations.readFileFromArchive(file, virtualPath); fileBytes = cz.kamma.kfmanager.service.FileOperations.readFileFromArchive(file, virtualPath);
boolean binary = isBinary(fileBytes); boolean binary = isBinaryForCurrentFile(fileBytes);
if (binary && readOnly) { if (binary && readOnly) {
hexMode = true; hexMode = true;
buildHexViewText(0L); buildHexViewText(0L);
@ -1029,6 +1333,7 @@ public class FileEditor extends JFrame {
} }
if (undoManager != null) undoManager.discardAllEdits(); if (undoManager != null) undoManager.discardAllEdits();
modified = false; modified = false;
updateMarkdownPreviewAfterLoad();
updateTitle(); updateTitle();
updateStatus(); updateStatus();
return; return;
@ -1058,7 +1363,7 @@ public class FileEditor extends JFrame {
} }
} }
boolean binaryProbe = isBinary(probe); boolean binaryProbe = isBinaryForCurrentFile(probe);
if (binaryProbe && readOnly && size > maxFullLoadBytes) { if (binaryProbe && readOnly && size > maxFullLoadBytes) {
// Open RAF and stream pages // Open RAF and stream pages
@ -1077,7 +1382,7 @@ public class FileEditor extends JFrame {
} else { } else {
// Small or text file: load fully // Small or text file: load fully
fileBytes = readFileBytesWithChunking(file, size); fileBytes = readFileBytesWithChunking(file, size);
boolean binary = isBinary(fileBytes); boolean binary = isBinaryForCurrentFile(fileBytes);
if (binary && readOnly) { if (binary && readOnly) {
hexMode = true; hexMode = true;
buildHexViewText(0L); buildHexViewText(0L);
@ -1098,6 +1403,7 @@ public class FileEditor extends JFrame {
} }
if (undoManager != null) undoManager.discardAllEdits(); if (undoManager != null) undoManager.discardAllEdits();
modified = false; modified = false;
updateMarkdownPreviewAfterLoad();
updateTitle(); updateTitle();
updateStatus(); updateStatus();
} catch (IOException e) { } 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) { private boolean isBinary(byte[] bytes) {
if (bytes == null || bytes.length == 0) return false; if (bytes == null || bytes.length == 0) return false;
int nonPrintable = 0; int nonPrintable = 0;
@ -1499,6 +1825,26 @@ public class FileEditor extends JFrame {
statusSelLabel.setText(" "); 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 { } else {
int caret = textArea.getCaretPosition(); int caret = textArea.getCaretPosition();
String text = textArea.getText(); String text = textArea.getText();