diff --git a/README.md b/README.md index 3bf17a7..c4f23e1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/export.py b/backend/export.py index 1020bb3..0a0bd8f 100644 --- a/backend/export.py +++ b/backend/export.py @@ -300,18 +300,20 @@ 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. Uses marked.js for client-side markdown rendering. - + Args: title: The note title (for and display) 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, @@ -681,7 +760,7 @@ def generate_export_html( headerIds: true, mangle: false }}); - + // Raw markdown content const markdown = `{escaped_content}`; @@ -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'); diff --git a/backend/main.py b/backend/main.py index 70e13af..d4cb453 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/documentation/API.md b/documentation/API.md index de9f240..90fbef5 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -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 diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 613f420..950be8a 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -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 diff --git a/documentation/MATHJAX.md b/documentation/MATHJAX.md index 8c895f7..b3222be 100644 --- a/documentation/MATHJAX.md +++ b/documentation/MATHJAX.md @@ -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} $$ diff --git a/frontend/app.js b/frontend/app.js index 013ddfe..4acd1a0 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 }, @@ -214,7 +215,10 @@ function noteApp() { // Hide underscore-prefixed folders (_attachments, _templates) from sidebar // 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' @@ -826,7 +830,28 @@ function noteApp() { this.hideUnderscoreFolders = !this.hideUnderscoreFolders; 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; - } - } - - 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); + // 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]; } } - // 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} - - - ${highlightTheme ? `` : ''} - - - - - - - - - - - - -
- ${renderedHTML} -
- -`; - - // 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; diff --git a/frontend/index.html b/frontend/index.html index 1fad5ea..a9ebe8d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -70,8 +70,8 @@