Merge pull request #198 from gamosoft/features/suggestions

Features/suggestions
This commit is contained in:
Guillermo Villar 2026-04-06 13:04:01 +02:00 committed by GitHub
commit 7590e6e712
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 417 additions and 291 deletions

View File

@ -62,7 +62,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
- 📱 **Responsive** - Works on desktop, tablet, and mobile
- 📂 **Simple Storage** - Plain markdown files in folders
- 🧮 **Math Support** - LaTeX/MathJax for beautiful equations
- 📄 **HTML Export** - Share notes as standalone HTML files
- 📄 **HTML Export & Print** - Export notes as standalone HTML or print
- 🕸️ **Graph View** - Interactive visualization of connected notes
- ⭐ **Favorites** - Star your most-used notes for instant access
- 📑 **Outline Panel** - Navigate headings with click-to-jump TOC

View File

@ -300,7 +300,8 @@ def generate_export_html(
title: str,
content: str,
theme_css: str,
is_dark: bool = False
is_dark: bool = False,
show_print_button: bool = False
) -> str:
"""
Generate a standalone HTML document for a note.
@ -311,6 +312,7 @@ def generate_export_html(
content: Raw markdown content (images should already be base64 embedded)
theme_css: CSS content for theming
is_dark: Whether using a dark theme (for Mermaid/Highlight.js)
show_print_button: Whether to show a print button (for preview mode)
Returns:
Complete HTML document as string
@ -327,6 +329,24 @@ def generate_export_html(
highlight_theme = 'github-dark' if is_dark else 'github'
mermaid_theme = 'dark' if is_dark else 'default'
# Print toolbar HTML (only shown in preview mode)
print_toolbar_html = '''
<div class="print-toolbar">
<button onclick="window.print()" title="Print">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
</svg>
Print
</button>
<button onclick="window.close()" title="Close">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Close
</button>
</div>
''' if show_print_button else ''
html = f'''<!DOCTYPE html>
<html lang="en">
<head>
@ -348,8 +368,8 @@ def generate_export_html(
<script>
MathJax = {{
tex: {{
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
inlineMath: [['\\\\(', '\\\\)'], ['$', '$']],
displayMath: [['\\\\[', '\\\\]'], ['$$', '$$']],
processEscapes: true,
processEnvironments: true
}},
@ -667,13 +687,72 @@ def generate_export_html(
padding: 0.5in;
max-width: none;
}}
.print-toolbar {{
display: none !important;
}}
}}
/* Print toolbar (only shown in preview mode) */
.print-toolbar {{
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
gap: 0.5rem;
background: var(--bg-secondary, #f8f9fa);
padding: 0.5rem;
border-radius: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 1px solid var(--border-primary, #dee2e6);
}}
.print-toolbar button {{
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary, #dee2e6);
border-radius: 0.375rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333333);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
transition: background-color 0.15s, border-color 0.15s;
}}
.print-toolbar button:hover {{
background: var(--bg-tertiary, #e9ecef);
border-color: var(--accent-primary, #0366d6);
}}
.print-toolbar button svg {{
width: 1rem;
height: 1rem;
}}
</style>
</head>
<body>
{print_toolbar_html}
<div class="markdown-preview" id="content"></div>
<script>
// Protect LaTeX delimiters \\(...\\) and \\[...\\] from marked.js escaping
marked.use({{
extensions: [{{
name: 'protectLatexMath',
level: 'inline',
start(src) {{ return src.match(/\\\\[\\(\\[]/)?.index; }},
tokenizer(src) {{
const match = src.match(/^(\\\\[\\(\\[])([\\s\\S]*?)(\\\\[\\)\\]])/);
if (match) {{
return {{ type: 'html', raw: match[0], text: match[0] }};
}}
}}
}}]
}});
// Configure marked
marked.setOptions({{
gfm: true,
@ -691,6 +770,11 @@ def generate_export_html(
const safeHtml = DOMPurify.sanitize(rawHtml);
document.getElementById('content').innerHTML = safeHtml;
// Typeset math after content is inserted
if (typeof MathJax !== 'undefined' && MathJax.typeset) {{
MathJax.typeset();
}}
// Add copy buttons to code blocks
document.querySelectorAll('.markdown-preview pre').forEach(pre => {{
const btn = document.createElement('button');

View File

@ -1228,6 +1228,95 @@ async def remove_note(request: Request, note_path: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete note"))
@api_router.get("/export/{note_path:path}", tags=["Export"])
@limiter.limit("30/minute")
async def export_note_to_html(request: Request, note_path: str, theme: Optional[str] = None, download: bool = True):
"""
Export a note as a standalone HTML file.
The HTML includes all necessary CSS, MathJax, Mermaid, and syntax highlighting
for offline viewing. Images are embedded as base64.
Query Parameters:
theme: Optional theme name (defaults to current theme or 'light')
download: If true (default), returns as file download. If false, displays in browser with print button.
Returns:
HTML file (download or inline based on download parameter)
"""
try:
notes_dir = Path(config['storage']['notes_dir'])
# Read note content
content = get_note_content(str(notes_dir), note_path)
if content is None:
raise HTTPException(status_code=404, detail="Note not found")
# Run on_note_load hook (can transform content, e.g., decrypt)
transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
if transformed_content is not None:
content = transformed_content
# Strip YAML frontmatter (like the preview does)
content = strip_frontmatter(content)
# Get note folder for resolving relative image paths
note_file_path = notes_dir / note_path
note_folder = note_file_path.parent
# Embed images as base64
content_with_images = embed_images_as_base64(content, note_folder, notes_dir)
# Convert wikilinks to decorative HTML links
content_with_links = convert_wikilinks_to_html(content_with_images)
# Get theme CSS
themes_dir = Path(__file__).parent.parent / "themes"
theme_name = theme or 'light'
theme_css = get_theme_css(str(themes_dir), theme_name)
if not theme_css:
theme_css = get_theme_css(str(themes_dir), "light")
theme_name = "light"
# Strip data-theme selector
theme_css = theme_css.replace(f':root[data-theme="{theme_name}"]', ':root')
theme_css = theme_css.replace(':root[data-theme="light"]', ':root')
theme_css = theme_css.replace(':root[data-theme="dark"]', ':root')
# Determine if dark theme
is_dark = 'dark' in theme_name.lower() or theme_name in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
# Get note title
title = Path(note_path).stem
# Generate HTML (show print button only when not downloading)
html_content = generate_export_html(
title=title,
content=content_with_links,
theme_css=theme_css,
is_dark=is_dark,
show_print_button=not download
)
# Return as downloadable file or inline (for print preview)
if download:
filename = f"{title}.html"
return Response(
content=html_content,
media_type="text/html",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)
else:
# Return inline for browser display (print preview)
return HTMLResponse(content=html_content)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to export note"))
@api_router.get("/search", tags=["Search"])
async def search(
q: str,

View File

@ -444,6 +444,55 @@ Returns the relationship graph between notes with link detection.
- **Markdown links** - `[text](note.md)` standard internal links
- **Edge types** - `"wikilink"` or `"markdown"` to distinguish link source
---
## 📤 Export
### Export Note as HTML
```http
GET /api/export/{note_path}?theme={theme_name}&download={true|false}
```
Exports a note as a standalone HTML file with all dependencies embedded for offline viewing.
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `note_path` | path | Path to the note (e.g., `folder/note.md`) |
| `theme` | query (optional) | Theme name for styling (defaults to `light`) |
| `download` | query (optional) | If `true` (default), returns as file download. If `false`, displays in browser with Print/Close buttons for print preview |
**Response:**
- `download=true`: Returns an HTML file with `Content-Disposition: attachment` header
- `download=false`: Returns inline HTML for browser display (print preview mode)
**Features:**
- Fully self-contained HTML with embedded CSS
- Images converted to base64 data URLs
- MathJax for LaTeX math rendering (supports `$...$`, `$$...$$`, `\(...\)`, `\[...\]`)
- Mermaid.js for diagram rendering
- Highlight.js for syntax highlighting
- Wikilinks converted to decorative spans
- YAML frontmatter stripped
- Responsive design with print support
- Print toolbar with Print/Close buttons (preview mode only)
**Rate Limit:** 30 requests/minute
**Example:**
```bash
# Export with default theme (downloads file)
curl -O http://localhost:8000/api/export/notes/Welcome.md
# Export with dark theme (downloads file)
curl -O "http://localhost:8000/api/export/docs/API.md?theme=dracula"
# Print preview (open in browser)
# http://localhost:8000/api/export/docs/API.md?theme=light&download=false
```
---
## ⚙️ System
### Get Config

View File

@ -12,7 +12,6 @@
- **Copy code blocks** - One-click copy button on hover
- **LaTeX/Math rendering** - Beautiful mathematical equations with MathJax (see [MATHJAX.md](MATHJAX.md))
- **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md))
- **HTML Export** - Export notes as standalone HTML files with embedded images
- **Public Sharing** - Share notes via token-based URLs with optional QR code for mobile (see [SHARING.md](SHARING.md))
### Media Support
@ -32,6 +31,14 @@
- **Rename anything** - Files and folders, instantly
- **Visual tree view** - Expandable/collapsible navigation
- **Hide system folders** - Toggle to hide `_attachments`, `_templates` and other underscore-prefixed folders from sidebar
- **Tab inserts tab** - Toggle to make Tab key insert a tab character in the editor instead of changing focus
### Export & Print
- **HTML Export** - Download notes as standalone HTML files with all styling, images, diagrams, and math embedded
- **Print Preview** - Open note in new tab with Print/Close buttons for easy printing
- **Self-contained** - Exported files work offline with no dependencies
- **Theme-aware** - Export uses your current theme for consistent appearance
- **Full rendering** - MathJax equations, Mermaid diagrams, and syntax highlighting included
## 🔗 Linking & Discovery

View File

@ -4,14 +4,18 @@ NoteDiscovery supports **LaTeX mathematical notation** powered by MathJax 3. Wri
## Syntax Overview
### Inline Math (within text)
Use `$...$` for inline equations:
| Delimiter | Type | Behavior |
|-----------|------|----------|
| `$...$` | Inline | Flows with text, not centered |
| `\(...\)` | Inline | Same as `$...$` (LaTeX standard) |
| `$$...$$` | Display | Own paragraph, centered, larger |
| `\[...\]` | Display | Same as `$$...$$` (LaTeX standard) |
- `$E = mc^2$` renders as: $E = mc^2$
- `$x^2 + y^2 = r^2$` renders as: $x^2 + y^2 = r^2$
### Inline Math (within text)
Inline math flows with your text: `$E = mc^2$` renders as $E = mc^2$
### Display Math (centered, on its own line)
Use `$$...$$` for display equations:
Display math gets its own centered paragraph:
```markdown
$$
@ -19,6 +23,13 @@ x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}
$$
```
Or using LaTeX-style delimiters:
```markdown
\[
x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}
\]
```
$$
x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}
$$

View File

@ -20,6 +20,7 @@ const LOCAL_SETTINGS = {
favoritesExpanded: { key: 'favoritesExpanded', type: 'boolean', default: true },
tagsExpanded: { key: 'tagsExpanded', type: 'boolean', default: false },
hideUnderscoreFolders: { key: 'hideUnderscoreFolders', type: 'boolean', default: false },
tabInsertsTab: { key: 'tabInsertsTab', type: 'boolean', default: false },
// Number settings with validation
sidebarWidth: { key: 'sidebarWidth', type: 'number', default: CONFIG.DEFAULT_SIDEBAR_WIDTH, min: 200, max: 600 },
editorWidth: { key: 'editorWidth', type: 'number', default: 50, min: 20, max: 80 },
@ -215,6 +216,9 @@ function noteApp() {
// Read synchronously to prevent flash on initial render
hideUnderscoreFolders: localStorage.getItem('hideUnderscoreFolders') === 'true',
// Tab key inserts tab character instead of changing focus
tabInsertsTab: localStorage.getItem('tabInsertsTab') === 'true',
// Icon rail / panel state
activePanel: 'files', // 'files', 'search', 'tags', 'settings'
@ -827,6 +831,27 @@ function noteApp() {
localStorage.setItem('hideUnderscoreFolders', this.hideUnderscoreFolders);
},
// Tab inserts tab toggle (Tab key inserts tab character instead of changing focus)
toggleTabInsertsTab() {
this.tabInsertsTab = !this.tabInsertsTab;
localStorage.setItem('tabInsertsTab', this.tabInsertsTab);
},
// Handle Tab key in editor (inserts tab if setting enabled)
handleTabKey(event) {
if (!this.tabInsertsTab) return;
event.preventDefault();
const textarea = event.target;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
this.noteContent = this.noteContent.substring(0, start) + '\t' + this.noteContent.substring(end);
this.$nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1;
});
this.autoSave();
},
// Update syntax highlight overlay (debounced, called on input)
updateSyntaxHighlight() {
if (!this.syntaxHighlightEnabled) return;
@ -4139,6 +4164,26 @@ function noteApp() {
return codeBlocks[parseInt(index)];
});
// Protect LaTeX \(...\) and \[...\] delimiters from marked.js escaping
marked.use({
extensions: [{
name: 'protectLatexMath',
level: 'inline',
start(src) { return src.match(/\\[\(\[]/)?.index; },
tokenizer(src) {
// Match \(...\) or \[...\]
const match = src.match(/^(\\[\(\[])([\s\S]*?)(\\[\)\]])/);
if (match) {
return {
type: 'html',
raw: match[0],
text: match[0]
};
}
}
}]
});
// Configure marked with syntax highlighting
marked.setOptions({
breaks: true,
@ -5020,7 +5065,7 @@ function noteApp() {
}, CONFIG.SCROLL_SYNC_DELAY);
},
// Export current note as HTML
// Export current note as HTML via backend API
async exportToHTML() {
if (!this.currentNote || !this.noteContent) {
alert(this.t('notes.no_content'));
@ -5028,278 +5073,39 @@ function noteApp() {
}
try {
// Get the note name without extension
const noteName = this.currentNoteName || 'note';
// Get current rendered HTML (this already has markdown converted and will have LaTeX delimiters)
let renderedHTML = this.renderedMarkdown;
// Convert non-image media (audio, video, PDF) to placeholders first
// These shouldn't be embedded as base64 (too large)
// Use CSS variables with fallbacks for theme-aware styling (matches backend export.py)
const mediaPlaceholder = (type, name) => {
const icons = { audio: '🎵', video: '🎬', document: '📄' };
const labels = { audio: 'Audio file', video: 'Video file', document: 'PDF document' };
const icon = icons[type] || '📎';
const label = labels[type] || 'Media file';
return `<div style="margin:1.5rem 0;padding:1.5rem;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;display:flex;align-items:center;gap:1rem;">
<span style="font-size:2rem;">${icon}</span>
<div>
<div style="font-weight:600;color:var(--text-primary,#212529);">${name}</div>
<div style="font-size:0.875rem;color:var(--text-secondary,#6c757d);">${label} not available in exported view</div>
</div>
</div>`;
};
// Replace audio embeds with placeholders
renderedHTML = renderedHTML.replace(
/<div class="media-embed media-audio">.*?<audio[^>]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs,
(match, filename) => mediaPlaceholder('audio', decodeURIComponent(filename).replace(/\.[^.]+$/, ''))
);
// Replace video embeds with placeholders
renderedHTML = renderedHTML.replace(
/<div class="media-embed media-video">.*?<video[^>]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs,
(match, filename) => mediaPlaceholder('video', decodeURIComponent(filename).replace(/\.[^.]+$/, ''))
);
// Replace PDF embeds with placeholders
renderedHTML = renderedHTML.replace(
/<div class="media-embed media-pdf">.*?<iframe[^>]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs,
(match, filename) => mediaPlaceholder('document', decodeURIComponent(filename).replace(/\.[^.]+$/, ''))
);
// Embed local images as base64 for fully self-contained HTML
// Handle both /api/media/ and legacy /api/images/ paths
const imgRegex = /src="\/api\/(?:media|images)\/([^"]+)"/g;
const imgMatches = [...renderedHTML.matchAll(imgRegex)];
for (const match of imgMatches) {
const encodedPath = match[1];
// Skip non-image files (already handled above)
const ext = encodedPath.split('.').pop().toLowerCase();
if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
continue;
}
try {
// Fetch the image
const imgResponse = await fetch(`/api/media/${encodedPath}`);
if (imgResponse.ok) {
const blob = await imgResponse.blob();
// Convert to base64 data URL
const base64 = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
// Replace the src with base64 data URL
renderedHTML = renderedHTML.replace(match[0], `src="${base64}"`);
}
} catch (e) {
console.warn(`Failed to embed image: ${encodedPath}`, e);
// Fall back to relative path
const decodedPath = decodeURIComponent(encodedPath);
renderedHTML = renderedHTML.replace(match[0], `src="${decodedPath}"`);
}
}
// Get current theme CSS
// Build API URL with current theme
const currentTheme = this.currentTheme || 'light';
const themeResponse = await fetch(`/api/themes/${currentTheme}`);
const themeText = await themeResponse.text();
const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}`;
// Check if response is JSON or plain CSS
let themeCss;
try {
const themeJson = JSON.parse(themeText);
// If it's JSON, extract the css field
themeCss = themeJson.css || themeText;
} catch (e) {
// If it's not JSON, use it as-is
themeCss = themeText;
// Fetch the exported HTML from backend
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Export failed' }));
throw new Error(error.detail || 'Export failed');
}
// Theme CSS uses :root[data-theme="..."] selector, but we need plain :root for export
// Strip the data-theme attribute selector so variables apply globally
themeCss = themeCss.replace(/:root\[data-theme="[^"]+"\]/g, ':root');
// Get highlight.js theme URL from current page
const highlightLinkElement = document.getElementById('highlight-theme');
if (!highlightLinkElement || !highlightLinkElement.href) {
console.warn('Could not detect highlight.js theme, export may not match preview exactly');
}
const highlightTheme = highlightLinkElement ? highlightLinkElement.href : '';
// Extract all markdown preview styles from current page
let markdownStyles = '';
const styleSheets = Array.from(document.styleSheets);
for (const sheet of styleSheets) {
try {
// Skip external stylesheets (CDN resources) to avoid CORS errors
// We link them directly in the exported HTML anyway
if (sheet.href && (sheet.href.startsWith('http://') || sheet.href.startsWith('https://'))) {
const currentOrigin = window.location.origin;
const sheetURL = new URL(sheet.href);
if (sheetURL.origin !== currentOrigin) {
// Skip cross-origin stylesheets (they're linked directly in export)
continue;
// Get filename from Content-Disposition header or use note name
let filename = (this.currentNoteName || 'note') + '.html';
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const match = contentDisposition.match(/filename="([^"]+)"/);
if (match) {
filename = match[1];
}
}
const rules = Array.from(sheet.cssRules || []);
for (const rule of rules) {
const cssText = rule.cssText;
// Include rules that target markdown-preview, mjx-container, or mermaid-rendered
if (cssText.includes('.markdown-preview') ||
cssText.includes('mjx-container') ||
cssText.includes('.MathJax') ||
cssText.includes('.mermaid-rendered')) {
markdownStyles += cssText + '\n';
}
}
} catch (e) {
// Gracefully skip stylesheets that can't be accessed
// (This should rarely happen now that we skip external stylesheets)
console.debug('Skipping stylesheet:', sheet.href);
}
}
// Create standalone HTML document with MathJax
const htmlDocument = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${noteName}</title>
<!-- Highlight.js for code syntax highlighting (v11.9.0) -->
${highlightTheme ? `<link rel="stylesheet" href="${highlightTheme}">` : '<!-- No highlight.js theme detected -->'}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- MathJax for LaTeX math rendering (v3.2.2) -->
<script>
MathJax = {
tex: {
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
processEscapes: true,
processEnvironments: true
},
options: {
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
},
startup: {
pageReady: () => {
return MathJax.startup.defaultPageReady().then(() => {
// Highlight code blocks after MathJax is done (exclude diagram renderers)
document.querySelectorAll('pre code:not(.language-mermaid)').forEach((block) => {
hljs.highlightElement(block);
});
});
}
}
};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js"></script>
<!-- Mermaid.js for diagrams -->
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.esm.min.mjs';
const isDark = ${this.getThemeType() === 'dark'};
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'inherit',
flowchart: { useMaxWidth: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
state: { useMaxWidth: true },
er: { useMaxWidth: true },
pie: { useMaxWidth: true },
mindmap: { useMaxWidth: true },
gitGraph: { useMaxWidth: true }
});
// Render any Mermaid code blocks
document.addEventListener('DOMContentLoaded', async () => {
const mermaidBlocks = document.querySelectorAll('pre code.language-mermaid');
for (let i = 0; i < mermaidBlocks.length; i++) {
const block = mermaidBlocks[i];
const pre = block.parentElement;
try {
const code = block.textContent;
const id = 'mermaid-diagram-' + i;
const { svg } = await mermaid.render(id, code);
const container = document.createElement('div');
container.className = 'mermaid-rendered';
container.style.cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;';
container.innerHTML = svg;
pre.parentElement.replaceChild(container, pre);
} catch (error) {
console.error('Mermaid rendering error:', error);
}
}
});
</script>
<style>
/* Theme CSS */
${themeCss}
/* Base styles */
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 2rem;
max-width: 900px;
margin-left: auto;
margin-right: auto;
background-color: var(--bg-primary);
color: var(--text-primary);
}
/* Markdown preview styles extracted from current page */
${markdownStyles}
@media (max-width: 768px) {
body {
padding: 1rem;
}
}
@media print {
body {
padding: 0.5in;
max-width: none;
}
}
</style>
</head>
<body>
<div class="markdown-preview">
${renderedHTML}
</div>
</body>
</html>`;
// Create blob and download
const blob = new Blob([htmlDocument], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
// Download as blob
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${noteName}.html`;
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
// Cleanup
URL.revokeObjectURL(url);
URL.revokeObjectURL(blobUrl);
document.body.removeChild(a);
} catch (error) {
@ -5308,6 +5114,22 @@ function noteApp() {
}
},
// Open print preview in new window
printPreview() {
if (!this.currentNote || !this.noteContent) {
alert(this.t('notes.no_content'));
return;
}
// Build API URL with current theme and download=false for inline display
const currentTheme = this.currentTheme || 'light';
const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}&download=false`;
// Open in new window/tab
window.open(url, '_blank');
},
// Copy current note link to clipboard
async copyNoteLink() {
if (!this.currentNote) return;

View File

@ -70,8 +70,8 @@
<script>
MathJax = {
tex: {
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
inlineMath: [['\\(', '\\)'], ['$', '$']],
displayMath: [['\\[', '\\]'], ['$$', '$$']],
processEscapes: true,
processEnvironments: true
},
@ -2038,6 +2038,24 @@
<p class="text-xs mt-1" style="color: var(--text-tertiary);" x-text="t('settings.hide_underscore_folders_desc')"></p>
</div>
<!-- Tab inserts tab -->
<div class="mb-4">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs font-medium" style="color: var(--text-secondary);" x-text="t('settings.tab_inserts_tab')"></span>
<div
@click="toggleTabInsertsTab()"
class="relative w-10 h-5 rounded-full transition-colors cursor-pointer"
:style="tabInsertsTab ? 'background-color: var(--accent-primary)' : 'background-color: var(--bg-tertiary)'"
>
<div
class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full transition-transform"
:style="'background-color: var(--bg-primary);' + (tabInsertsTab ? ' transform: translateX(20px);' : '')"
></div>
</div>
</label>
<p class="text-xs mt-1" style="color: var(--text-tertiary);" x-text="t('settings.tab_inserts_tab_desc')"></p>
</div>
<!-- Logout (if auth enabled) - uses authEnabled from main app state -->
<div x-show="authEnabled" class="mb-4">
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);" x-text="t('settings.account')"></label>
@ -2524,6 +2542,18 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</button>
<button
@click="printPreview()"
class="p-2 transition"
style="color: var(--text-secondary);"
:title="t('toolbar.print_preview')"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
</svg>
</button>
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
<button
@click="copyNoteLink()"
@ -2644,6 +2674,7 @@
x-model="noteContent"
@input="autoSave(); updateSyntaxHighlight()"
@scroll="syncOverlayScroll()"
@keydown.tab="handleTabKey($event)"
@drop="onEditorDrop($event)"
@dragover.prevent="onEditorDragOver($event)"
@dragenter="onEditorDragEnter($event)"

View File

@ -105,6 +105,7 @@
"delete_note": "Notiz löschen",
"delete_image": "Bild löschen",
"export_html": "Als HTML exportieren",
"print_preview": "Druckvorschau",
"copy_link": "Link in Zwischenablage kopieren",
"add_favorite": "Zu Favoriten hinzufügen",
"remove_favorite": "Aus Favoriten entfernen"
@ -250,7 +251,9 @@
"readable_line_length": "Lesbare Zeilenlänge",
"readable_line_length_desc": "Vorschaubreite für bessere Lesbarkeit begrenzen",
"hide_underscore_folders": "Systemordner ausblenden",
"hide_underscore_folders_desc": "_attachments, _templates und andere Unterstrich-Ordner ausblenden"
"hide_underscore_folders_desc": "_attachments, _templates und andere Unterstrich-Ordner ausblenden",
"tab_inserts_tab": "Tab-Taste fügt Tab ein",
"tab_inserts_tab_desc": "Tab drücken, um ein Tabulatorzeichen einzufügen statt den Fokus zu wechseln"
},
"homepage": {

View File

@ -104,6 +104,7 @@
"delete_note": "Delete note",
"delete_image": "Delete image",
"export_html": "Export as HTML",
"print_preview": "Print preview",
"copy_link": "Copy link to clipboard",
"add_favorite": "Add to favourites",
"remove_favorite": "Remove from favourites"
@ -249,7 +250,9 @@
"readable_line_length": "Readable line length",
"readable_line_length_desc": "Limit preview width for easier reading",
"hide_underscore_folders": "Hide system folders",
"hide_underscore_folders_desc": "Hide _attachments, _templates and other underscore folders from sidebar"
"hide_underscore_folders_desc": "Hide _attachments, _templates and other underscore folders from sidebar",
"tab_inserts_tab": "Tab key inserts tab",
"tab_inserts_tab_desc": "Press Tab to insert a tab character instead of changing focus"
},
"homepage": {

View File

@ -105,6 +105,7 @@
"delete_note": "Delete note",
"delete_image": "Delete image",
"export_html": "Export as HTML",
"print_preview": "Print preview",
"copy_link": "Copy link to clipboard",
"add_favorite": "Add to favorites",
"remove_favorite": "Remove from favorites"
@ -250,7 +251,9 @@
"readable_line_length": "Readable line length",
"readable_line_length_desc": "Limit preview width for easier reading",
"hide_underscore_folders": "Hide system folders",
"hide_underscore_folders_desc": "Hide _attachments, _templates and other underscore folders from sidebar"
"hide_underscore_folders_desc": "Hide _attachments, _templates and other underscore folders from sidebar",
"tab_inserts_tab": "Tab key inserts tab",
"tab_inserts_tab_desc": "Press Tab to insert a tab character instead of changing focus"
},
"homepage": {

View File

@ -105,6 +105,7 @@
"delete_note": "Eliminar nota",
"delete_image": "Eliminar imagen",
"export_html": "Exportar como HTML",
"print_preview": "Vista previa de impresión",
"copy_link": "Copiar enlace al portapapeles",
"add_favorite": "Añadir a favoritos",
"remove_favorite": "Quitar de favoritos"
@ -250,7 +251,9 @@
"readable_line_length": "Longitud de línea legible",
"readable_line_length_desc": "Limitar el ancho de vista previa para facilitar la lectura",
"hide_underscore_folders": "Ocultar carpetas de sistema",
"hide_underscore_folders_desc": "Ocultar _attachments, _templates y otras carpetas con guion bajo"
"hide_underscore_folders_desc": "Ocultar _attachments, _templates y otras carpetas con guion bajo",
"tab_inserts_tab": "Tabulador inserta tabulación",
"tab_inserts_tab_desc": "Pulsa Tab para insertar un tabulador en lugar de cambiar el foco"
},
"homepage": {

View File

@ -105,6 +105,7 @@
"delete_note": "Supprimer la note",
"delete_image": "Supprimer l'image",
"export_html": "Exporter en HTML",
"print_preview": "Aperçu avant impression",
"copy_link": "Copier le lien dans le presse-papiers",
"add_favorite": "Ajouter aux favoris",
"remove_favorite": "Retirer des favoris"
@ -250,7 +251,9 @@
"readable_line_length": "Longueur de ligne lisible",
"readable_line_length_desc": "Limiter la largeur de l'aperçu pour une lecture plus facile",
"hide_underscore_folders": "Masquer les dossiers système",
"hide_underscore_folders_desc": "Masquer _attachments, _templates et autres dossiers préfixés"
"hide_underscore_folders_desc": "Masquer _attachments, _templates et autres dossiers préfixés",
"tab_inserts_tab": "Tab insère une tabulation",
"tab_inserts_tab_desc": "Appuyez sur Tab pour insérer une tabulation au lieu de changer le focus"
},
"homepage": {

View File

@ -105,6 +105,7 @@
"delete_note": "Jegyzet törlése",
"delete_image": "Kép törlése",
"export_html": "Exportálás HTML-ként",
"print_preview": "Nyomtatási előnézet",
"copy_link": "Link másolása",
"add_favorite": "Hozzáadás a kedvencekhez",
"remove_favorite": "Eltávolítás a kedvencek közül"
@ -250,7 +251,9 @@
"readable_line_length": "Olvasható sorhossz",
"readable_line_length_desc": "Korlátozd az előnézeti szélességet a könnyebb olvasás érdekében",
"hide_underscore_folders": "Rendszermappák elrejtése",
"hide_underscore_folders_desc": "_attachments, _templates és más aláhúzásos mappák elrejtése"
"hide_underscore_folders_desc": "_attachments, _templates és más aláhúzásos mappák elrejtése",
"tab_inserts_tab": "Tab billentyű tabulátort szúr be",
"tab_inserts_tab_desc": "Nyomja meg a Tab-ot tabulátor beszúrásához a fókuszváltás helyett"
},
"homepage": {

View File

@ -104,6 +104,7 @@
"delete_note": "Elimina nota",
"delete_image": "Elimina immagine",
"export_html": "Esporta come HTML",
"print_preview": "Anteprima di stampa",
"copy_link": "Copia link negli appunti",
"add_favorite": "Aggiungi ai preferiti",
"remove_favorite": "Rimuovi dai preferiti"
@ -249,7 +250,9 @@
"readable_line_length": "Larghezza riga leggibile",
"readable_line_length_desc": "Limita la larghezza dell'anteprima per una lettura più facile",
"hide_underscore_folders": "Nascondi cartelle di sistema",
"hide_underscore_folders_desc": "Nascondi _attachments, _templates e altre cartelle con underscore"
"hide_underscore_folders_desc": "Nascondi _attachments, _templates e altre cartelle con underscore",
"tab_inserts_tab": "Tab inserisce tabulazione",
"tab_inserts_tab_desc": "Premi Tab per inserire una tabulazione invece di cambiare focus"
},
"homepage": {

View File

@ -104,6 +104,7 @@
"delete_note": "ノートを削除",
"delete_image": "画像を削除",
"export_html": "HTMLとしてエクスポート",
"print_preview": "印刷プレビュー",
"copy_link": "リンクをクリップボードにコピー",
"add_favorite": "お気に入りに追加",
"remove_favorite": "お気に入りから削除"
@ -249,7 +250,9 @@
"readable_line_length": "読みやすい行幅",
"readable_line_length_desc": "プレビューの幅を制限して読みやすくする",
"hide_underscore_folders": "システムフォルダを非表示",
"hide_underscore_folders_desc": "_attachments、_templatesなどのフォルダをサイドバーから非表示"
"hide_underscore_folders_desc": "_attachments、_templatesなどのフォルダをサイドバーから非表示",
"tab_inserts_tab": "Tabキーでタブを挿入",
"tab_inserts_tab_desc": "Tabキーでフォーカス移動ではなくタブ文字を挿入"
},
"homepage": {

View File

@ -104,6 +104,7 @@
"delete_note": "Удалить заметку",
"delete_image": "Удалить изображение",
"export_html": "Экспорт в HTML",
"print_preview": "Предварительный просмотр печати",
"copy_link": "Копировать ссылку",
"add_favorite": "Добавить в избранное",
"remove_favorite": "Удалить из избранного"
@ -249,7 +250,9 @@
"readable_line_length": "Удобная длина строки",
"readable_line_length_desc": "Ограничить ширину предпросмотра для удобства чтения",
"hide_underscore_folders": "Скрыть системные папки",
"hide_underscore_folders_desc": "Скрыть _attachments, _templates и другие папки с подчёркиванием"
"hide_underscore_folders_desc": "Скрыть _attachments, _templates и другие папки с подчёркиванием",
"tab_inserts_tab": "Tab вставляет табуляцию",
"tab_inserts_tab_desc": "Нажмите Tab для вставки табуляции вместо переключения фокуса"
},
"homepage": {

View File

@ -104,6 +104,7 @@
"delete_note": "Izbriši zapis",
"delete_image": "Izbriši sliko",
"export_html": "Izvozi kot HTML",
"print_preview": "Predogled tiskanja",
"copy_link": "Kopiraj povezavo v odložišče",
"add_favorite": "Dodaj med priljubljene",
"remove_favorite": "Odstrani iz priljubljenih"
@ -249,7 +250,9 @@
"readable_line_length": "Berljiva dolžina vrstice",
"readable_line_length_desc": "Omejite širino predogleda za lažje branje",
"hide_underscore_folders": "Skrij sistemske mape",
"hide_underscore_folders_desc": "Skrij _attachments, _templates in druge mape s podčrtajem"
"hide_underscore_folders_desc": "Skrij _attachments, _templates in druge mape s podčrtajem",
"tab_inserts_tab": "Tipka Tab vstavi tabulator",
"tab_inserts_tab_desc": "Pritisnite Tab za vstavljanje tabulatorja namesto premikanja fokusa"
},
"homepage": {

View File

@ -104,6 +104,7 @@
"delete_note": "删除笔记",
"delete_image": "删除图片",
"export_html": "导出为 HTML",
"print_preview": "打印预览",
"copy_link": "复制链接到剪贴板",
"add_favorite": "添加到收藏夹",
"remove_favorite": "从收藏夹移除"
@ -249,7 +250,9 @@
"readable_line_length": "可读行宽",
"readable_line_length_desc": "限制预览宽度以便于阅读",
"hide_underscore_folders": "隐藏系统文件夹",
"hide_underscore_folders_desc": "从侧边栏隐藏 _attachments、_templates 等下划线文件夹"
"hide_underscore_folders_desc": "从侧边栏隐藏 _attachments、_templates 等下划线文件夹",
"tab_inserts_tab": "Tab键插入制表符",
"tab_inserts_tab_desc": "按Tab键插入制表符而不是切换焦点"
},
"homepage": {