added MD support
This commit is contained in:
parent
a4dedd1324
commit
de1ae57da7
@ -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
|
||||
|
||||
@ -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("<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("&", "&")
|
||||
.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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user