Add terminal, Pan mode, zoom controls and shell commands
This commit is contained in:
@@ -9,6 +9,8 @@ clap = { version = "4.5", features = ["derive"] }
|
||||
anyhow = "1.0"
|
||||
dirs = "5"
|
||||
open = "5"
|
||||
tao = "0.30"
|
||||
wry = "0.54"
|
||||
|
||||
[[bin]]
|
||||
name = "md_reader"
|
||||
|
||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# MD Reader
|
||||
|
||||
Markdown 文件閱讀器,支援 Mermaid 圖表、PDF 匯出、原生視窗預覽。
|
||||
|
||||
## 安裝
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## 功能
|
||||
|
||||
### 1. Markdown 轉 HTML
|
||||
|
||||
```bash
|
||||
./target/release/md_reader render docs/example.md
|
||||
# 輸出到 ~/docs/html/example.html
|
||||
```
|
||||
|
||||
### 2. 原生視窗預覽
|
||||
|
||||
```bash
|
||||
./target/release/md_reader preview docs/example.md
|
||||
```
|
||||
|
||||
### 3. 批次轉換
|
||||
|
||||
```bash
|
||||
./target/release/md_reader batch ./docs
|
||||
```
|
||||
|
||||
### 4. PDF 匯出
|
||||
|
||||
```bash
|
||||
./target/release/md_reader export docs/example.md -o output.pdf
|
||||
# 或使用 render -p
|
||||
./target/release/md_reader render docs/example.md -p
|
||||
```
|
||||
|
||||
### 5. HTTP 伺服器
|
||||
|
||||
```bash
|
||||
./target/release/md_reader server --port 8080 --path ./docs
|
||||
```
|
||||
|
||||
## 視窗功能
|
||||
|
||||
### 工具列按鈕
|
||||
|
||||
| 按鈕 | 功能 |
|
||||
|------|------|
|
||||
| Print / Save as PDF | 列印或儲存為 PDF |
|
||||
| Download All SVGs | 下載所有 Mermaid 圖表為 SVG |
|
||||
| Pan | 滑鼠拖曳平移內容 |
|
||||
| − / + | 縮小 / 放大 |
|
||||
| Reset | 重置縮放為 100% |
|
||||
|
||||
### 終端機命令
|
||||
|
||||
按 `/` 開啟終端輸入框。
|
||||
|
||||
| 命令 | 功能 |
|
||||
|------|------|
|
||||
| `/help` | 顯示幫助訊息 |
|
||||
| `/clear` | 清除終端輸出 |
|
||||
| `/print` | 開啟列印對話框 |
|
||||
| `/svg` | 下載所有 SVG 圖表 |
|
||||
| `/reload` | 重新載入頁面 |
|
||||
| `/zoom [N]` | 設定縮放 (50-200%) |
|
||||
| `/zoom in` 或 `/zoom +` | 放大 10% |
|
||||
| `/zoom out` 或 `/zoom -` | 縮小 10% |
|
||||
| `/zoom reset` 或 `/zoom 0` | 重置為 100% |
|
||||
|
||||
### Shell 命令
|
||||
|
||||
直接輸入 shell 命令(不帶 `/`)執行:
|
||||
|
||||
```bash
|
||||
ls # 列出檔案
|
||||
pwd # 顯示目前目錄
|
||||
cd <dir> # 切換目錄
|
||||
cat <file> # 顯示檔案內容
|
||||
```
|
||||
|
||||
### Pan 模式
|
||||
|
||||
1. 點擊工具列的 `Pan` 按鈕
|
||||
2. 按住滑鼠左鍵拖曳移動內容
|
||||
3. 再次點擊 `Pan` 按鈕關閉
|
||||
|
||||
### 快捷鍵
|
||||
|
||||
| 按鍵 | 功能 |
|
||||
|------|------|
|
||||
| `/` | 開啟終端輸入框 |
|
||||
| `Esc` | 關閉終端輸入框 |
|
||||
|
||||
## Mermaid 圖表支援
|
||||
|
||||
在 Markdown 中使用 ```mermaid 程式碼塊:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Start] --> B[End]
|
||||
```
|
||||
````
|
||||
|
||||
支援的圖表類型:
|
||||
- Flowchart (流程圖)
|
||||
- Sequence Diagram (序列圖)
|
||||
- Class Diagram (類別圖)
|
||||
- State Diagram (狀態圖)
|
||||
- Entity Relationship Diagram (ER 圖)
|
||||
- Gantt Chart (甘特圖)
|
||||
- Pie Chart (圓餅圖)
|
||||
|
||||
## 專案結構
|
||||
|
||||
```
|
||||
md_reader/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ └── main.rs # 主要程式碼
|
||||
└── docs/
|
||||
└── mermaid.md # Mermaid 範例文件
|
||||
```
|
||||
|
||||
## 依賴
|
||||
|
||||
- Rust 2021 edition
|
||||
- tao (視窗管理)
|
||||
- wry (WebView)
|
||||
- pulldown-cmark (Markdown 解析)
|
||||
- clap (命令列參數)
|
||||
693
src/main.rs
693
src/main.rs
@@ -20,6 +20,8 @@ enum Commands {
|
||||
file: String,
|
||||
#[arg(short, long, help = "Output file path")]
|
||||
output: Option<String>,
|
||||
#[arg(short = 'p', long, help = "Export as PDF")]
|
||||
pdf: bool,
|
||||
},
|
||||
Batch {
|
||||
#[arg(help = "Directory containing markdown files")]
|
||||
@@ -43,6 +45,14 @@ enum Commands {
|
||||
#[arg(short = 'e', long, default_value = "700")]
|
||||
height: u32,
|
||||
},
|
||||
Export {
|
||||
#[arg(help = "Markdown file to export")]
|
||||
file: String,
|
||||
#[arg(short, long, help = "Output file path")]
|
||||
output: String,
|
||||
#[arg(short, long, default_value = "pdf", value_parser = ["pdf", "html"])]
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn render_markdown(content: &str) -> String {
|
||||
@@ -121,13 +131,23 @@ fn wrap_in_html(title: &str, content: &str) -> String {
|
||||
<title>{}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; }}
|
||||
html, body {{ margin: 0; padding: 0; height: 100%; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
padding: 20px;
|
||||
padding-bottom: 0;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
overflow-y: auto;
|
||||
height: calc(100% - 100px);
|
||||
}}
|
||||
.content {{
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}}
|
||||
pre {{
|
||||
background: #f4f4f4;
|
||||
@@ -183,6 +203,8 @@ fn wrap_in_html(title: &str, content: &str) -> String {
|
||||
.mermaid svg {{
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}}
|
||||
.mermaid-download {{
|
||||
position: absolute;
|
||||
@@ -204,20 +226,172 @@ fn wrap_in_html(title: &str, content: &str) -> String {
|
||||
.mermaid-download:hover {{
|
||||
background: #0056b3;
|
||||
}}
|
||||
.toolbar {{
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 1000;
|
||||
}}
|
||||
.toolbar h1 {{
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}}
|
||||
.toolbar button {{
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}}
|
||||
.toolbar button:hover {{
|
||||
background: #2980b9;
|
||||
}}
|
||||
.toolbar .zoom-controls {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}}
|
||||
.toolbar .zoom-level {{
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
min-width: 45px;
|
||||
text-align: center;
|
||||
}}
|
||||
body {{ padding-top: 50px; padding-bottom: 0; }}
|
||||
.content-wrapper {{
|
||||
height: calc(100% - 100px);
|
||||
overflow-y: auto;
|
||||
transform-origin: top center;
|
||||
}}
|
||||
.content {{ max-width: 800px; margin: 0 auto; }}
|
||||
.content-inner {{
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
transform-origin: top center;
|
||||
}}
|
||||
.content-wrapper.zoomed {{
|
||||
transform-origin: top center;
|
||||
}}
|
||||
.terminal {{
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100px;
|
||||
background: #1e1e1e;
|
||||
border-top: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
}}
|
||||
.terminal-output {{
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}}
|
||||
.terminal-output .error {{ color: #f48771; }}
|
||||
.terminal-output .success {{ color: #89d185; }}
|
||||
.terminal-input-row {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: #252526;
|
||||
border-top: 1px solid #333;
|
||||
}}
|
||||
.terminal-prompt {{
|
||||
color: #007acc;
|
||||
margin-right: 8px;
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.terminal-hint {{
|
||||
position: fixed;
|
||||
bottom: 105px;
|
||||
right: 16px;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
background: #1e1e1e;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
.terminal-input {{
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}}
|
||||
.terminal-input:focus {{
|
||||
background: rgba(255,255,255,0.05);
|
||||
}}
|
||||
.pan-mode {{ cursor: grab; }}
|
||||
.pan-mode.panning {{ cursor: grabbing; }}
|
||||
.pan-active {{ cursor: grab; }}
|
||||
.pan-active.panning {{ cursor: grabbing; }}
|
||||
.pan-button {{
|
||||
background: #6c757d !important;
|
||||
}}
|
||||
.pan-button.active {{
|
||||
background: #495057 !important;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<h1>{}</h1>
|
||||
<button onclick="window.print()">Print / Save as PDF</button>
|
||||
<button onclick="downloadAllSvg()">Download All SVGs</button>
|
||||
<button onclick="togglePan()" id="pan-btn" class="pan-button">Pan</button>
|
||||
<div class="zoom-controls">
|
||||
<button onclick="zoomOut()">−</button>
|
||||
<span class="zoom-level" id="zoom-level">100%</span>
|
||||
<button onclick="zoomIn()">+</button>
|
||||
<button onclick="resetZoom()">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-wrapper" id="content-wrapper">
|
||||
<div class="content-inner" id="content-inner" style="padding: 20px;">
|
||||
{}
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-hint">Press / to enter command mode</div>
|
||||
<div class="terminal">
|
||||
<div class="terminal-output" id="terminal-output"></div>
|
||||
<div class="terminal-input-row">
|
||||
<span class="terminal-prompt">></span>
|
||||
<input type="text" class="terminal-input" id="terminal-input" placeholder="Enter command..." autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let svgData = [];
|
||||
async function renderMermaidDiagrams() {{
|
||||
const mermaidDivs = document.querySelectorAll('.mermaid');
|
||||
svgData = [];
|
||||
for (let i = 0; i < mermaidDivs.length; i++) {{
|
||||
const div = mermaidDivs[i];
|
||||
const code = div.textContent.trim();
|
||||
const id = 'mermaid-' + i;
|
||||
try {{
|
||||
const {{ svg }} = await mermaid.render(id, code);
|
||||
div.innerHTML = svg + '<button class="mermaid-download" onclick="downloadSvg(this)" data-svg="' + encodeURIComponent(svg) + '">Download SVG</button>';
|
||||
svgData.push({{ id: i, svg: svg }});
|
||||
const scaledSvg = svg.replace(/<svg/, '<svg style="max-width:100%;height:auto;"');
|
||||
div.innerHTML = scaledSvg + '<button class="mermaid-download" onclick="downloadSingleSvg(' + i + ')">Download SVG</button>';
|
||||
}} catch (err) {{
|
||||
console.error('Mermaid render error:', err);
|
||||
div.innerHTML = '<pre style="text-align:left;color:red;">Error: ' + err.message + '</pre>';
|
||||
@@ -225,25 +399,205 @@ fn wrap_in_html(title: &str, content: &str) -> String {
|
||||
}}
|
||||
}}
|
||||
|
||||
function downloadSvg(btn) {{
|
||||
const svg = decodeURIComponent(btn.getAttribute('data-svg'));
|
||||
const blob = new Blob([svg], {{ type: 'image/svg+xml' }});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'diagram-' + Date.now() + '.svg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
function downloadSingleSvg(index) {{
|
||||
const data = svgData.find(d => d.id === index);
|
||||
if (data) {{
|
||||
const blob = new Blob([data.svg], {{ type: 'image/svg+xml' }});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'diagram-' + index + '-' + Date.now() + '.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
}}
|
||||
|
||||
function downloadAllSvg() {{
|
||||
svgData.forEach((data, index) => {{
|
||||
setTimeout(() => {{
|
||||
const blob = new Blob([data.svg], {{ type: 'image/svg+xml' }});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'diagram-' + index + '-' + Date.now() + '.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}, index * 200);
|
||||
}});
|
||||
}}
|
||||
|
||||
function appendOutput(text, type = '') {{
|
||||
const output = document.getElementById('terminal-output');
|
||||
const line = document.createElement('div');
|
||||
line.className = type;
|
||||
line.textContent = text;
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}}
|
||||
|
||||
let currentZoom = 100;
|
||||
|
||||
function zoomIn() {{
|
||||
currentZoom = Math.min(currentZoom + 10, 200);
|
||||
applyZoom();
|
||||
}}
|
||||
|
||||
function zoomOut() {{
|
||||
currentZoom = Math.max(currentZoom - 10, 50);
|
||||
applyZoom();
|
||||
}}
|
||||
|
||||
function resetZoom() {{
|
||||
currentZoom = 100;
|
||||
applyZoom();
|
||||
}}
|
||||
|
||||
function applyZoom() {{
|
||||
const wrapper = document.getElementById('content-wrapper');
|
||||
const scale = currentZoom / 100;
|
||||
wrapper.style.transform = 'scale(' + scale + ')';
|
||||
wrapper.style.height = (100 / scale) + '%';
|
||||
document.getElementById('zoom-level').textContent = currentZoom + '%';
|
||||
}}
|
||||
|
||||
function executeCommand(cmd) {{
|
||||
appendOutput('> ' + cmd);
|
||||
const cmdName = cmd.replace(/^\/+/, '');
|
||||
const cmdParts = cmdName.split(' ');
|
||||
const baseCmd = cmdParts[0];
|
||||
const subCmd = cmdParts.slice(1).join(' ');
|
||||
|
||||
if (baseCmd === 'help') {{
|
||||
appendOutput('Commands (prefix with /):');
|
||||
appendOutput(' /help - Show this help message');
|
||||
appendOutput(' /clear - Clear terminal output');
|
||||
appendOutput(' /print - Print / Save as PDF');
|
||||
appendOutput(' /svg - Download all SVG diagrams');
|
||||
appendOutput(' /reload - Reload page');
|
||||
appendOutput(' /zoom - Show current zoom level');
|
||||
appendOutput(' /zoom in | + - Zoom in (max 200%)');
|
||||
appendOutput(' /zoom out | - - Zoom out (min 50%)');
|
||||
appendOutput(' /zoom <N> - Set zoom to N% (50-200)');
|
||||
appendOutput(' /zoom reset | 0 - Reset zoom to 100%');
|
||||
appendOutput(' /term - Activate terminal mode');
|
||||
appendOutput(' /zoom - Show current zoom level');
|
||||
appendOutput(' /zoom in | + - Zoom in (max 200%)');
|
||||
appendOutput(' /zoom out | - - Zoom out (min 50%)');
|
||||
appendOutput(' /zoom <N> - Set zoom to N% (50-200)');
|
||||
appendOutput(' /zoom reset | 0 - Reset zoom to 100%');
|
||||
appendOutput('');
|
||||
appendOutput('Shell commands (without /): ls, pwd, cd, cat, etc.');
|
||||
}} else if (baseCmd === 'clear') {{
|
||||
document.getElementById('terminal-output').innerHTML = '';
|
||||
}} else if (baseCmd === 'print') {{
|
||||
window.print();
|
||||
appendOutput('Opening print dialog...', 'success');
|
||||
}} else if (baseCmd === 'svg') {{
|
||||
downloadAllSvg();
|
||||
appendOutput('Downloading SVGs...', 'success');
|
||||
}} else if (baseCmd === 'reload') {{
|
||||
window.location.reload();
|
||||
}} else if (baseCmd === 'zoom') {{
|
||||
if (subCmd === 'in' || subCmd === '+') {{
|
||||
zoomIn();
|
||||
appendOutput('Zoom: ' + currentZoom + '%', 'success');
|
||||
}} else if (subCmd === 'out' || subCmd === '-') {{
|
||||
zoomOut();
|
||||
appendOutput('Zoom: ' + currentZoom + '%', 'success');
|
||||
}} else if (subCmd === 'reset' || subCmd === '0') {{
|
||||
resetZoom();
|
||||
appendOutput('Zoom reset to 100%', 'success');
|
||||
}} else {{
|
||||
const level = parseInt(subCmd);
|
||||
if (!isNaN(level) && level >= 50 && level <= 200) {{
|
||||
currentZoom = level;
|
||||
applyZoom();
|
||||
appendOutput('Zoom: ' + currentZoom + '%', 'success');
|
||||
}} else {{
|
||||
appendOutput('Usage: /zoom [in|out|+|-|reset|0|<50-200>]', 'error');
|
||||
}}
|
||||
}}
|
||||
}} else if (baseCmd === '' || cmd.trim() === '') {{
|
||||
}} else if (!cmd.startsWith('/')) {{
|
||||
if (window.execShell) {{
|
||||
window.execShell(cmd);
|
||||
}} else {{
|
||||
appendOutput('Shell execution not available', 'error');
|
||||
}}
|
||||
}} else {{
|
||||
appendOutput('Unknown command: ' + baseCmd + '. Type /help for available commands.', 'error');
|
||||
}}
|
||||
}}
|
||||
|
||||
document.getElementById('terminal-input').addEventListener('keydown', function(e) {{
|
||||
if (e.key === 'Escape') {{
|
||||
this.blur();
|
||||
}}
|
||||
if (e.key === 'Enter' && this.value.trim() !== '') {{
|
||||
const cmd = this.value.trim();
|
||||
this.value = '';
|
||||
executeCommand(cmd);
|
||||
}}
|
||||
}});
|
||||
|
||||
window.addEventListener('keydown', function(e) {{
|
||||
if (e.key === '/' && document.activeElement.id !== 'terminal-input' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {{
|
||||
e.preventDefault();
|
||||
document.getElementById('terminal-input').focus();
|
||||
}}
|
||||
}});
|
||||
|
||||
let panState = {{ isEnabled: false, isPanning: false, startX: 0, startY: 0, offsetX: 0, offsetY: 0 }};
|
||||
|
||||
window.togglePan = function() {{
|
||||
panState.isEnabled = !panState.isEnabled;
|
||||
const btn = document.getElementById('pan-btn');
|
||||
if (panState.isEnabled) {{
|
||||
btn.classList.add('active');
|
||||
appendOutput('Pan mode ON - drag to move', 'success');
|
||||
}} else {{
|
||||
btn.classList.remove('active');
|
||||
panState.offsetX = 0;
|
||||
panState.offsetY = 0;
|
||||
document.getElementById('content-inner').style.transform = 'translate(0px, 0px)';
|
||||
appendOutput('Pan mode OFF', 'success');
|
||||
}}
|
||||
}};
|
||||
|
||||
document.addEventListener('mousedown', function(e) {{
|
||||
if (panState.isEnabled && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'INPUT' && e.target.id !== 'terminal-input') {{
|
||||
panState.isPanning = true;
|
||||
panState.startX = e.clientX - panState.offsetX;
|
||||
panState.startY = e.clientY - panState.offsetY;
|
||||
e.preventDefault();
|
||||
}}
|
||||
}});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {{
|
||||
if (panState.isPanning) {{
|
||||
panState.offsetX = e.clientX - panState.startX;
|
||||
panState.offsetY = e.clientY - panState.startY;
|
||||
document.getElementById('content-inner').style.transform = 'translate(' + panState.offsetX + 'px, ' + panState.offsetY + 'px)';
|
||||
}}
|
||||
}});
|
||||
|
||||
document.addEventListener('mouseup', function() {{
|
||||
panState.isPanning = false;
|
||||
}});
|
||||
|
||||
document.addEventListener('mouseleave', function() {{
|
||||
panState.isPanning = false;
|
||||
}});
|
||||
|
||||
window.terminalExecute = executeCommand;
|
||||
|
||||
mermaid.initialize({{ startOnLoad: false }});
|
||||
renderMermaidDiagrams();
|
||||
appendOutput('Type /help for available commands. Press / to enter commands.', 'success');
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
title, content
|
||||
title, title, content
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,27 +793,276 @@ fn list_directory(path: &Path) -> String {
|
||||
files.join("\n")
|
||||
}
|
||||
|
||||
fn preview_markdown(file_path: &str, title: &str, _width: u32, _height: u32) -> Result<()> {
|
||||
fn preview_markdown(file_path: &str, title: &str, width: u32, height: u32) -> Result<()> {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::process;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tao::event::{Event, WindowEvent};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tao::window::WindowBuilder;
|
||||
use wry::WebViewBuilder;
|
||||
|
||||
let path = Path::new(file_path);
|
||||
let content = read_file(path)?;
|
||||
let html_content = render_markdown(&content);
|
||||
let full_html = wrap_in_html(title, &html_content);
|
||||
|
||||
let port: u16 = (9000 + (process::id() % 1000)) as u16;
|
||||
let port_clone = port;
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Ok(listener) = TcpListener::bind(format!("127.0.0.1:{}", port_clone)) {
|
||||
for stream in listener.incoming() {
|
||||
if let Ok(mut stream) = stream {
|
||||
let mut buffer = [0; 4096];
|
||||
if let Ok(_) = stream.read(&mut buffer) {
|
||||
let request = String::from_utf8_lossy(&buffer);
|
||||
if request.starts_with("POST /cmd ") {
|
||||
let cmd = request.lines().last().unwrap_or("").trim();
|
||||
let cmd_trimmed = cmd.trim();
|
||||
if cmd_trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = cmd_trimmed.split_whitespace().collect();
|
||||
let output = if parts[0] == "cd" {
|
||||
if let Some(dir) = parts.get(1) {
|
||||
match std::env::set_current_dir(dir) {
|
||||
Ok(_) => format!("Changed to: {}", dir),
|
||||
Err(e) => format!("cd: {}: {}", dir, e),
|
||||
}
|
||||
} else {
|
||||
"cd: missing directory".to_string()
|
||||
}
|
||||
} else {
|
||||
let result = std::process::Command::new(parts[0])
|
||||
.args(&parts[1..])
|
||||
.output();
|
||||
match result {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if stdout.is_empty() && !stderr.is_empty() {
|
||||
stderr.to_string()
|
||||
} else if stdout.is_empty() && stderr.is_empty() {
|
||||
"(done)".to_string()
|
||||
} else {
|
||||
stdout.to_string()
|
||||
}
|
||||
}
|
||||
Err(e) => format!("Error: {}: {}", parts[0], e),
|
||||
}
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}",
|
||||
output.len(),
|
||||
output
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
} else {
|
||||
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
|
||||
let full_html = full_html.replace(
|
||||
"// __TERMINAL_EXEC__PLACEHOLDER__",
|
||||
&format!(
|
||||
r#"
|
||||
window.execShell = function(cmd) {{
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', 'http://127.0.0.1:{}/cmd', false);
|
||||
xhr.send(cmd);
|
||||
if (xhr.responseText) {{
|
||||
appendOutput(xhr.responseText);
|
||||
}}
|
||||
}};
|
||||
"#,
|
||||
port
|
||||
),
|
||||
);
|
||||
|
||||
let event_loop = EventLoopBuilder::new().build();
|
||||
let window = WindowBuilder::new()
|
||||
.with_title(title)
|
||||
.with_inner_size(tao::dpi::LogicalSize::new(width as f64, height as f64))
|
||||
.build(&event_loop)?;
|
||||
|
||||
let _webview = WebViewBuilder::new().with_html(&full_html).build(&window)?;
|
||||
|
||||
event_loop.run(move |event, _, control_flow| {
|
||||
*control_flow = ControlFlow::Wait;
|
||||
if let Event::WindowEvent {
|
||||
event: WindowEvent::CloseRequested,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
*control_flow = ControlFlow::Exit;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn wrap_for_pdf(title: &str, content: &str) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
@page {{
|
||||
margin: 20mm;
|
||||
size: A4;
|
||||
}}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
font-size: 12pt;
|
||||
}}
|
||||
h1, h2, h3, h4, h5, h6 {{
|
||||
page-break-after: avoid;
|
||||
}}
|
||||
pre {{
|
||||
background: #f4f4f4;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
page-break-inside: avoid;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
code {{
|
||||
background: #f4f4f4;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
pre code {{
|
||||
background: none;
|
||||
padding: 0;
|
||||
}}
|
||||
blockquote {{
|
||||
border-left: 3px solid #ddd;
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
color: #666;
|
||||
}}
|
||||
table {{
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
page-break-inside: avoid;
|
||||
}}
|
||||
th, td {{
|
||||
border: 1px solid #ddd;
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
}}
|
||||
th {{
|
||||
background: #f4f4f4;
|
||||
}}
|
||||
img {{
|
||||
max-width: 100%;
|
||||
page-break-inside: avoid;
|
||||
}}
|
||||
.mermaid {{
|
||||
background: white;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
page-break-inside: avoid;
|
||||
}}
|
||||
.mermaid svg {{
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}}
|
||||
.mermaid-download {{
|
||||
display: none;
|
||||
}}
|
||||
@media print {{
|
||||
body {{
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}}
|
||||
.mermaid svg {{
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{}
|
||||
<script>
|
||||
async function renderMermaidDiagrams() {{
|
||||
const mermaidDivs = document.querySelectorAll('.mermaid');
|
||||
for (let i = 0; i < mermaidDivs.length; i++) {{
|
||||
const div = mermaidDivs[i];
|
||||
const code = div.textContent.trim();
|
||||
const id = 'mermaid-' + i;
|
||||
try {{
|
||||
const {{ svg }} = await mermaid.render(id, code);
|
||||
div.innerHTML = svg;
|
||||
}} catch (err) {{
|
||||
console.error('Mermaid render error:', err);
|
||||
div.innerHTML = '<pre style="text-align:left;color:red;">Error: ' + err.message + '</pre>';
|
||||
}}
|
||||
}}
|
||||
setTimeout(function() {{ window.print(); }}, 1500);
|
||||
}}
|
||||
mermaid.initialize({{ startOnLoad: false }});
|
||||
renderMermaidDiagrams();
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
title, content
|
||||
)
|
||||
}
|
||||
|
||||
fn export_pdf(file_path: &str, output: Option<String>) -> Result<()> {
|
||||
let path = Path::new(file_path);
|
||||
let content = read_file(path)?;
|
||||
let html = render_markdown(&content);
|
||||
let full_html = wrap_in_html(title, &html);
|
||||
let title = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Markdown");
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let safe_title: String = title
|
||||
.replace(' ', "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
|
||||
.collect();
|
||||
let temp_file = temp_dir.join(format!("{}.html", safe_title));
|
||||
println!("Temp file: {}", temp_file.display());
|
||||
let pdf_html = wrap_for_pdf(title, &html);
|
||||
|
||||
std::fs::write(&temp_file, &full_html)
|
||||
.with_context(|| format!("Failed to write temp file: {}", temp_file.display()))?;
|
||||
let out_path = if let Some(o) = output {
|
||||
o
|
||||
} else {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
let out_dir = home.join("docs/pdf");
|
||||
std::fs::create_dir_all(&out_dir)?;
|
||||
out_dir
|
||||
.join(format!("{}.html", title))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
open::that(&temp_file).with_context(|| format!("Failed to open file in browser"))?;
|
||||
std::fs::write(&out_path, &pdf_html)
|
||||
.with_context(|| format!("Failed to write to {}", out_path))?;
|
||||
|
||||
println!("PDF export file: {}", out_path);
|
||||
println!("Open this file in browser and use File > Print > Save as PDF");
|
||||
|
||||
open::that(&out_path).context("Failed to open file in browser")?;
|
||||
|
||||
println!("Opening preview in browser... (close browser window to exit)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -467,10 +1070,17 @@ fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Render { file, output } => {
|
||||
if let Err(e) = process_file(&file, output) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
Commands::Render { file, output, pdf } => {
|
||||
if pdf {
|
||||
if let Err(e) = export_pdf(&file, output) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = process_file(&file, output) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::Batch { directory, output } => {
|
||||
@@ -503,5 +1113,22 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Commands::Export {
|
||||
file,
|
||||
output,
|
||||
format,
|
||||
} => {
|
||||
if format == "pdf" {
|
||||
if let Err(e) = export_pdf(&file, Some(output)) {
|
||||
eprintln!("Export error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = process_file(&file, Some(output)) {
|
||||
eprintln!("Export error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user