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("").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("").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 "" + 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();