// NoteDiscovery Frontend Application // Configuration constants const CONFIG = { AUTOSAVE_DELAY: 1000, // ms - Delay before triggering autosave SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference SCROLL_SYNC_MAX_RETRIES: 10, // Maximum attempts to find editor/preview elements SCROLL_SYNC_RETRY_INTERVAL: 100, // ms - Time between setupScrollSync retries MAX_UNDO_HISTORY: 50, // Maximum number of undo steps to keep DEFAULT_SIDEBAR_WIDTH: 256, // px - Default sidebar width (w-64 in Tailwind) }; // Centralized error handling const ErrorHandler = { /** * Handle errors consistently across the app * @param {string} operation - The operation that failed (e.g., "load notes", "save note") * @param {Error} error - The error object * @param {boolean} showAlert - Whether to show an alert to the user */ handle(operation, error, showAlert = true) { // Always log to console for debugging console.error(`Failed to ${operation}:`, error); // Show user-friendly alert if requested if (showAlert) { alert(`Failed to ${operation}. Please try again.`); } } }; function noteApp() { return { // App state appName: 'NoteDiscovery', appTagline: 'Your Self-Hosted Knowledge Base', appVersion: '0.0.0', authEnabled: false, notes: [], currentNote: '', currentNoteName: '', noteContent: '', viewMode: 'split', // 'edit', 'split', 'preview' searchQuery: '', // Graph state (separate overlay, doesn't affect viewMode) showGraph: false, graphInstance: null, graphLoaded: false, graphData: null, searchResults: [], currentSearchHighlight: '', // Track current highlighted search term currentMatchIndex: 0, // Current match being viewed totalMatches: 0, // Total number of matches in the note isSaving: false, lastSaved: false, saveTimeout: null, // Theme state currentTheme: 'light', availableThemes: [], // Syntax highlighting syntaxHighlightEnabled: false, syntaxHighlightTimeout: null, // Icon rail / panel state activePanel: 'files', // 'files', 'search', 'tags', 'settings' // Folder state folderTree: [], allFolders: [], expandedFolders: new Set(), draggedNote: null, draggedFolder: null, dragOverFolder: null, // Track which folder is being hovered during drag // Tags state allTags: {}, selectedTags: [], tagsExpanded: false, tagReloadTimeout: null, // For debouncing tag reloads // Scroll sync state isScrolling: false, // Unified drag state draggedItem: null, // { path: string, type: 'note' | 'image' } dropTarget: null, // 'editor' | 'folder' | null // Undo/Redo history undoHistory: [], redoHistory: [], maxHistorySize: CONFIG.MAX_UNDO_HISTORY, isUndoRedo: false, // Stats plugin state statsPluginEnabled: false, noteStats: null, statsExpanded: false, // Note metadata (frontmatter) state noteMetadata: null, metadataExpanded: false, _lastFrontmatter: null, // Cache to avoid re-parsing unchanged frontmatter // Sidebar resize state sidebarWidth: CONFIG.DEFAULT_SIDEBAR_WIDTH, isResizing: false, // Mobile sidebar state mobileSidebarOpen: false, // Split view resize state editorWidth: 50, // percentage isResizingSplit: false, // Dropdown state showNewDropdown: false, dropdownTargetFolder: null, // Folder context for "New" dropdown ('' = root, null = not set) dropdownPosition: { top: 0, left: 0 }, // Position for contextual dropdown // Template state showTemplateModal: false, availableTemplates: [], selectedTemplate: '', newTemplateNoteName: '', // Homepage state selectedHomepageFolder: '', _homepageCache: { folderPath: null, notes: null, folders: null, breadcrumb: null }, // Homepage constants HOMEPAGE_MAX_NOTES: 50, // Computed-like helpers for homepage (cached for performance) homepageNotes() { // Return cached result if folder hasn't changed if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.notes) { return this._homepageCache.notes; } if (!this.folderTree || typeof this.folderTree !== 'object') { return []; } const folderNode = this.getFolderNode(this.selectedHomepageFolder || ''); const result = (folderNode && Array.isArray(folderNode.notes)) ? folderNode.notes : []; // Cache the result this._homepageCache.notes = result; this._homepageCache.folderPath = this.selectedHomepageFolder; return result; }, homepageFolders() { // Return cached result if folder hasn't changed if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.folders) { return this._homepageCache.folders; } if (!this.folderTree || typeof this.folderTree !== 'object') { return []; } // Get child folders let childFolders = []; if (!this.selectedHomepageFolder) { // Root level: all top-level folders childFolders = Object.entries(this.folderTree) .filter(([key]) => key !== '__root__') .map(([, folder]) => folder); } else { // Inside a folder: get its children const parentFolder = this.getFolderNode(this.selectedHomepageFolder); if (parentFolder && parentFolder.children) { childFolders = Object.values(parentFolder.children); } } // Map to simplified structure (note count already cached in folder node) const result = childFolders .map(folder => ({ name: folder.name, path: folder.path, noteCount: folder.noteCount || 0 // Use pre-calculated count })) .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); // Cache the result this._homepageCache.folders = result; this._homepageCache.folderPath = this.selectedHomepageFolder; return result; }, homepageBreadcrumb() { // Return cached result if folder hasn't changed if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.breadcrumb) { return this._homepageCache.breadcrumb; } const breadcrumb = [{ name: 'Home', path: '' }]; if (this.selectedHomepageFolder) { const parts = this.selectedHomepageFolder.split('/').filter(Boolean); let currentPath = ''; parts.forEach(part => { currentPath = currentPath ? `${currentPath}/${part}` : part; breadcrumb.push({ name: part, path: currentPath }); }); } // Cache the result this._homepageCache.breadcrumb = breadcrumb; this._homepageCache.folderPath = this.selectedHomepageFolder; return breadcrumb; }, // Helper: Format file size nicely formatSize(bytes) { if (!bytes) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }, getFolderNode(folderPath = '') { if (!this.folderTree || typeof this.folderTree !== 'object') { return null; } if (!folderPath) { return this.folderTree['__root__'] || { name: '', path: '', children: {}, notes: [], noteCount: 0 }; } const parts = folderPath.split('/').filter(Boolean); let currentLevel = this.folderTree; let node = null; for (const part of parts) { if (!currentLevel[part]) { return null; } node = currentLevel[part]; currentLevel = node.children || {}; } return node; }, // Check if app is empty (no notes and no folders) get isAppEmpty() { const notesArray = Array.isArray(this.notes) ? this.notes : []; const foldersArray = Array.isArray(this.allFolders) ? this.allFolders : []; return notesArray.length === 0 && foldersArray.length === 0; }, // Mermaid state cache lastMermaidTheme: null, // Image viewer state currentImage: '', // DOM element cache (to avoid repeated querySelector calls) _domCache: { editor: null, previewContainer: null, previewContent: null }, // Initialize app async init() { // Store global reference for native event handlers in x-html content window.$root = this; // ESC key to cancel drag operations document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && (this.draggedNote || this.draggedFolder || this.draggedItem)) { this.cancelDrag(); } }); await this.loadConfig(); await this.loadThemes(); await this.initTheme(); await this.loadNotes(); await this.loadTemplates(); await this.checkStatsPlugin(); this.loadSidebarWidth(); this.loadEditorWidth(); this.loadViewMode(); this.loadTagsExpanded(); this.loadSyntaxHighlightSetting(); // Parse URL and load specific note if provided this.loadNoteFromURL(); // Set initial homepage state ONLY if we're actually on the homepage if (window.location.pathname === '/') { window.history.replaceState({ homepageFolder: '' }, '', '/'); } // Listen for browser back/forward navigation window.addEventListener('popstate', (e) => { if (e.state && e.state.notePath) { // Navigating to a note const searchQuery = e.state.searchQuery || ''; this.loadNote(e.state.notePath, false, searchQuery); // false = don't update history // Update search box and trigger search if needed if (searchQuery) { this.searchQuery = searchQuery; this.searchNotes(); } else { this.searchQuery = ''; this.searchResults = []; this.clearSearchHighlights(); } } else { // Navigating back to homepage this.currentNote = ''; this.noteContent = ''; this.currentNoteName = ''; // Restore homepage folder state if it was saved if (e.state && e.state.homepageFolder !== undefined) { this.selectedHomepageFolder = e.state.homepageFolder || ''; } else { // No folder state in history, go to root this.selectedHomepageFolder = ''; } // Invalidate cache to force recalculation this._homepageCache = { folderPath: null, notes: null, folders: null, breadcrumb: null }; // Clear search this.searchQuery = ''; this.searchResults = []; this.clearSearchHighlights(); } }); // Cache DOM references after initial render this.$nextTick(() => { this.refreshDOMCache(); }); // Setup mobile view mode handler this.setupMobileViewMode(); // Watch view mode changes and auto-save this.$watch('viewMode', (newValue) => { this.saveViewMode(); // Scroll to top when switching modes this.$nextTick(() => { this.scrollToTop(); }); }); // Watch for changes in note content to re-apply search highlights this.$watch('noteContent', () => { if (this.currentSearchHighlight) { // Re-apply highlights after content changes (with small delay for render) this.$nextTick(() => { setTimeout(() => { // Don't focus editor during content changes (false) this.highlightSearchTerm(this.currentSearchHighlight, false); }, 50); }); } }); // Watch tags panel expanded state and save to localStorage this.$watch('tagsExpanded', () => { this.saveTagsExpanded(); }); // Setup keyboard shortcuts (only once to prevent double triggers) if (!window.__noteapp_shortcuts_initialized) { window.__noteapp_shortcuts_initialized = true; window.addEventListener('keydown', (e) => { // Ctrl/Cmd + S to save if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); this.saveNote(); } // Ctrl/Cmd + Alt + N for new note if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === 'n') { e.preventDefault(); this.createNote(); } // Ctrl/Cmd + Alt + F for new folder if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === 'f') { e.preventDefault(); this.createFolder(); } // Ctrl/Cmd + Z for undo if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') { e.preventDefault(); this.undo(); } // Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z for redo if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); this.redo(); } // F3 for next search match if (e.key === 'F3' && !e.shiftKey) { e.preventDefault(); this.nextMatch(); } // Shift + F3 for previous search match if (e.key === 'F3' && e.shiftKey) { e.preventDefault(); this.previousMatch(); } // Only apply markdown shortcuts when editor is focused and a note is open const isEditorFocused = document.activeElement?.id === 'note-editor'; if (isEditorFocused && this.currentNote) { // Ctrl/Cmd + B for bold if ((e.ctrlKey || e.metaKey) && e.key === 'b') { e.preventDefault(); this.wrapSelection('**', '**', 'bold text'); } // Ctrl/Cmd + I for italic if ((e.ctrlKey || e.metaKey) && e.key === 'i') { e.preventDefault(); this.wrapSelection('*', '*', 'italic text'); } // Ctrl/Cmd + K for link if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); this.insertLink(); } } }); } // Note: setupScrollSync() is called when a note is loaded (see loadNote()) // Listen for system theme changes if (window.matchMedia) { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (this.currentTheme === 'system') { this.applyTheme('system'); } }); } }, // Load app configuration async loadConfig() { try { const response = await fetch('/api/config'); const config = await response.json(); this.appName = config.name; this.appTagline = config.tagline; this.appVersion = config.version || '0.0.0'; this.authEnabled = config.authentication?.enabled || false; } catch (error) { console.error('Failed to load config:', error); } }, // Load available themes from backend async loadThemes() { try { const response = await fetch('/api/themes'); const data = await response.json(); // Use theme names directly from backend (already include emojis) this.availableThemes = data.themes; } catch (error) { console.error('Failed to load themes:', error); // Fallback to default themes this.availableThemes = [ { id: 'light', name: '🌞 Light' }, { id: 'dark', name: '🌙 Dark' } ]; } }, // Initialize theme system async initTheme() { // Load saved theme preference from localStorage const savedTheme = localStorage.getItem('noteDiscoveryTheme') || 'light'; this.currentTheme = savedTheme; await this.applyTheme(savedTheme); }, // Set and apply theme async setTheme(themeId) { this.currentTheme = themeId; localStorage.setItem('noteDiscoveryTheme', themeId); await this.applyTheme(themeId); }, // Syntax highlighting toggle toggleSyntaxHighlight() { this.syntaxHighlightEnabled = !this.syntaxHighlightEnabled; localStorage.setItem('syntaxHighlightEnabled', this.syntaxHighlightEnabled); if (this.syntaxHighlightEnabled) { this.updateSyntaxHighlight(); } }, loadSyntaxHighlightSetting() { try { const saved = localStorage.getItem('syntaxHighlightEnabled'); this.syntaxHighlightEnabled = saved === 'true'; } catch (error) { console.error('Error loading syntax highlight setting:', error); } }, // Update syntax highlight overlay (debounced, called on input) updateSyntaxHighlight() { if (!this.syntaxHighlightEnabled) return; clearTimeout(this.syntaxHighlightTimeout); this.syntaxHighlightTimeout = setTimeout(() => { const overlay = document.getElementById('syntax-overlay'); if (overlay) { overlay.innerHTML = this.highlightMarkdown(this.noteContent); } }, 50); // 50ms debounce }, // Sync overlay scroll with textarea syncOverlayScroll() { const textarea = document.getElementById('note-editor'); const overlay = document.getElementById('syntax-overlay'); if (textarea && overlay) { overlay.scrollTop = textarea.scrollTop; overlay.scrollLeft = textarea.scrollLeft; } }, // Highlight markdown syntax highlightMarkdown(text) { if (!text) return ''; // Escape HTML first let html = this.escapeHtml(text); // Frontmatter (must be at start) html = html.replace(/^(---\n[\s\S]*?\n---)/m, '$1'); // Code blocks (before other patterns to avoid conflicts) html = html.replace(/(```[\s\S]*?```)/g, '$1'); // Headings html = html.replace(/^(#{1,6})\s(.*)$/gm, '$1 $2'); // Bold (must come before italic) html = html.replace(/\*\*([^*]+)\*\*/g, '**$1**'); html = html.replace(/__([^_]+)__/g, '__$1__'); // Italic html = html.replace(/(?*$1*'); html = html.replace(/(?_$1_'); // Inline code html = html.replace(/`([^`\n]+)`/g, '`$1`'); // Wikilinks [[...]] html = html.replace(/\[\[([^\]]+)\]\]/g, '[[$1]]'); // Links [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1]($2)'); // Lists html = html.replace(/^(\s*)([-*+])\s/gm, '$1$2 '); html = html.replace(/^(\s*)(\d+\.)\s/gm, '$1$2 '); // Blockquotes html = html.replace(/^(>.*)$/gm, '$1'); // Horizontal rules html = html.replace(/^([-*_]{3,})$/gm, '$1'); return html; }, // Apply theme to document async applyTheme(themeId) { // Load theme CSS from file try { const response = await fetch(`/api/themes/${themeId}`); const data = await response.json(); // Create or update style element let styleEl = document.getElementById('dynamic-theme'); if (!styleEl) { styleEl = document.createElement('style'); styleEl.id = 'dynamic-theme'; document.head.appendChild(styleEl); } styleEl.textContent = data.css; // Set data attribute for theme-specific selectors document.documentElement.setAttribute('data-theme', themeId); // Load appropriate Highlight.js theme for code syntax highlighting const highlightTheme = document.getElementById('highlight-theme'); if (highlightTheme) { if (themeId === 'light') { highlightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; } else { // Use dark theme for dark/custom themes highlightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css'; } } // Re-render Mermaid diagrams with new theme if there's a current note if (this.currentNote) { // Small delay to allow theme CSS to load setTimeout(() => { // Clear existing Mermaid renders const previewContent = document.querySelector('.markdown-preview'); if (previewContent) { const mermaidContainers = previewContent.querySelectorAll('.mermaid-rendered'); mermaidContainers.forEach(container => { // Replace with the original code block for re-rendering const parent = container.parentElement; if (parent && container.dataset.originalCode) { const pre = document.createElement('pre'); const code = document.createElement('code'); code.className = 'language-mermaid'; code.textContent = container.dataset.originalCode; pre.appendChild(code); parent.replaceChild(pre, container); } }); } // Re-render with new theme this.renderMermaid(); }, 100); } // Refresh graph if visible (longer delay to ensure CSS is applied) if (this.showGraph) { setTimeout(() => this.initGraph(), 300); } } catch (error) { console.error('Failed to load theme:', error); } }, // Load all notes async loadNotes() { try { const response = await fetch('/api/notes'); const data = await response.json(); this.notes = data.notes; this.allFolders = data.folders || []; this.buildFolderTree(); await this.loadTags(); // Load tags after notes are loaded } catch (error) { ErrorHandler.handle('load notes', error); } }, // Load all tags async loadTags() { try { const response = await fetch('/api/tags'); const data = await response.json(); this.allTags = data.tags || {}; } catch (error) { ErrorHandler.handle('load tags', error, false); // Don't show alert, tags are optional } }, // Debounced tag reload (prevents excessive API calls during typing) loadTagsDebounced() { // Clear existing timeout if (this.tagReloadTimeout) { clearTimeout(this.tagReloadTimeout); } // Set new timeout - reload tags 2 seconds after last save this.tagReloadTimeout = setTimeout(() => { this.loadTags(); }, 2000); }, // Toggle tag selection for filtering toggleTag(tag) { const index = this.selectedTags.indexOf(tag); if (index > -1) { this.selectedTags.splice(index, 1); } else { this.selectedTags.push(tag); } // Apply unified filtering this.applyFilters(); }, // ======================================================================== // Template Methods // ======================================================================== // Load available templates from _templates folder async loadTemplates() { try { const response = await fetch('/api/templates'); const data = await response.json(); this.availableTemplates = data.templates || []; } catch (error) { ErrorHandler.handle('load templates', error, false); // Don't show alert, templates are optional } }, // Create a new note from a template async createNoteFromTemplate() { if (!this.selectedTemplate || !this.newTemplateNoteName.trim()) { return; } try { // Determine the note path based on dropdown context let notePath = this.newTemplateNoteName.trim(); if (!notePath.endsWith('.md')) { notePath += '.md'; } // Determine target folder: use dropdown context if set, otherwise homepage folder let targetFolder; if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) { targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path } else { targetFolder = this.selectedHomepageFolder || ''; } // If we have a target folder, create note in that folder if (targetFolder) { notePath = `${targetFolder}/${notePath}`; } // CRITICAL: Check if note already exists const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { alert(`A note named "${this.newTemplateNoteName.trim()}" already exists in this location.\nPlease choose a different name.`); return; } // Create note from template const response = await fetch('/api/templates/create-note', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ templateName: this.selectedTemplate, notePath: notePath }) }); if (!response.ok) { const error = await response.json(); alert(error.detail || 'Failed to create note from template'); return; } const data = await response.json(); // Close modal and reset state this.showTemplateModal = false; this.selectedTemplate = ''; this.newTemplateNoteName = ''; // Reload notes and open the new note await this.loadNotes(); await this.loadNote(data.path); } catch (error) { ErrorHandler.handle('create note from template', error); } }, // Clear all tag filters clearTagFilters() { this.selectedTags = []; // Apply unified filtering this.applyFilters(); }, // Unified filtering logic combining tags and text search async applyFilters() { const hasTextSearch = this.searchQuery.trim().length > 0; const hasTagFilter = this.selectedTags.length > 0; // Case 1: No filters at all → show full folder tree if (!hasTextSearch && !hasTagFilter) { this.searchResults = []; this.currentSearchHighlight = ''; this.clearSearchHighlights(); this.buildFolderTree(); return; } // Case 2: Only tag filter → convert to flat list of matching notes if (hasTagFilter && !hasTextSearch) { this.searchResults = this.notes.filter(note => note.type === 'note' && this.noteMatchesTags(note) ); this.currentSearchHighlight = ''; this.clearSearchHighlights(); return; } // Case 3: Text search (with or without tag filter) if (hasTextSearch) { try { const response = await fetch(`/api/search?q=${encodeURIComponent(this.searchQuery)}`); const data = await response.json(); // Apply tag filtering to search results if tags are selected let results = data.results; if (hasTagFilter) { results = results.filter(result => { const note = this.notes.find(n => n.path === result.path); return note ? this.noteMatchesTags(note) : false; }); } this.searchResults = results; // Highlight search term in current note if open if (this.currentNote && this.noteContent) { this.currentSearchHighlight = this.searchQuery; this.$nextTick(() => { this.highlightSearchTerm(this.searchQuery, false); }); } } catch (error) { console.error('Search failed:', error); } } }, // Check if a note matches selected tags (AND logic) noteMatchesTags(note) { if (this.selectedTags.length === 0) { return true; // No filter active } if (!note.tags || note.tags.length === 0) { return false; // Note has no tags but filter is active } // Check if note has ALL selected tags (AND logic) return this.selectedTags.every(tag => note.tags.includes(tag)); }, // Get all tags sorted by name get sortedTags() { return Object.entries(this.allTags).sort((a, b) => a[0].localeCompare(b[0])); }, // Get tags for current note get currentNoteTags() { if (!this.currentNote) return []; const note = this.notes.find(n => n.path === this.currentNote); return note && note.tags ? note.tags : []; }, // Parse tags from markdown content (matches backend logic) parseTagsFromContent(content) { if (!content || !content.trim().startsWith('---')) { return []; } try { const lines = content.split('\n'); if (lines[0].trim() !== '---') return []; // Find closing --- let endIdx = -1; for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { endIdx = i; break; } } if (endIdx === -1) return []; const frontmatterLines = lines.slice(1, endIdx); const tags = []; let inTagsList = false; for (const line of frontmatterLines) { const stripped = line.trim(); // Check for inline array: tags: [tag1, tag2] if (stripped.startsWith('tags:')) { const rest = stripped.substring(5).trim(); if (rest.startsWith('[') && rest.endsWith(']')) { const tagsStr = rest.substring(1, rest.length - 1); const rawTags = tagsStr.split(',').map(t => t.trim()); tags.push(...rawTags.filter(t => t).map(t => t.toLowerCase())); break; } else if (rest) { tags.push(rest.toLowerCase()); break; } else { inTagsList = true; } } else if (inTagsList) { if (stripped.startsWith('-')) { const tag = stripped.substring(1).trim(); if (tag && !tag.startsWith('#')) { tags.push(tag.toLowerCase()); } } else if (stripped && !stripped.startsWith('#')) { break; } } } return [...new Set(tags)].sort(); } catch (e) { console.error('Error parsing tags:', e); return []; } }, // Build folder tree structure buildFolderTree() { const tree = {}; // Add ALL folders from backend (including empty ones) this.allFolders.forEach(folderPath => { const parts = folderPath.split('/'); let current = tree; parts.forEach((part, index) => { const fullPath = parts.slice(0, index + 1).join('/'); if (!current[part]) { current[part] = { name: part, path: fullPath, children: {}, notes: [] }; } current = current[part].children; }); }); // Add ALL notes to their folders (no filtering - tree only shown when no filters active) this.notes.forEach(note => { if (!note.folder) { // Root level note if (!tree['__root__']) { tree['__root__'] = { name: '', path: '', children: {}, notes: [] }; } tree['__root__'].notes.push(note); } else { // Navigate to the folder and add note const parts = note.folder.split('/'); let current = tree; for (let i = 0; i < parts.length; i++) { if (!current[parts[i]]) { current[parts[i]] = { name: parts[i], path: parts.slice(0, i + 1).join('/'), children: {}, notes: [] }; } if (i === parts.length - 1) { current[parts[i]].notes.push(note); } else { current = current[parts[i]].children; } } } }); // Sort all notes arrays alphabetically (create new sorted arrays for reactivity) const sortNotes = (obj) => { if (obj.notes && obj.notes.length > 0) { // Create a new sorted array instead of mutating for Alpine reactivity obj.notes = [...obj.notes].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); } if (obj.children && Object.keys(obj.children).length > 0) { Object.values(obj.children).forEach(child => sortNotes(child)); } }; // Sort notes in root (create new array for reactivity) if (tree['__root__'] && tree['__root__'].notes) { tree['__root__'].notes = [...tree['__root__'].notes].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); } // Sort notes in all folders Object.values(tree).forEach(folder => { if (folder.path !== undefined) { // Skip __root__ as it was already sorted sortNotes(folder); } }); // Calculate and cache note counts recursively (for performance) const calculateNoteCounts = (folderNode) => { const directNotes = folderNode.notes ? folderNode.notes.length : 0; if (!folderNode.children || Object.keys(folderNode.children).length === 0) { folderNode.noteCount = directNotes; return directNotes; } const childNotesCount = Object.values(folderNode.children).reduce( (total, child) => total + calculateNoteCounts(child), 0 ); folderNode.noteCount = directNotes + childNotesCount; return folderNode.noteCount; }; // Calculate note counts for all folders Object.values(tree).forEach(folder => { if (folder.path !== undefined || folder === tree['__root__']) { calculateNoteCounts(folder); } }); // Invalidate homepage cache when tree is rebuilt this._homepageCache = { folderPath: null, notes: null, folders: null, breadcrumb: null }; // Assign new tree (Alpine will detect the change) this.folderTree = tree; }, // Render folder recursively (helper for deep nesting) renderFolderRecursive(folder, level = 0, isTopLevel = false) { if (!folder) return ''; let html = ''; const isExpanded = this.expandedFolders.has(folder.path); // Render this folder's header // Note: Using native event handlers (ondragstart, onclick, etc.) instead of Alpine directives // because x-html doesn't process Alpine directives in dynamically generated content const escapedPath = folder.path.replace(/'/g, "\\'").replace(/\\/g, "\\\\"); html += `
Nothing to preview yet...
'; // Strip YAML frontmatter from content before rendering let contentToRender = this.noteContent; if (contentToRender.trim().startsWith('---')) { const lines = contentToRender.split('\n'); if (lines[0].trim() === '---') { // Find closing --- let endIdx = -1; for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { endIdx = i; break; } } if (endIdx !== -1) { // Remove frontmatter (including the closing ---) and any empty lines after it contentToRender = lines.slice(endIdx + 1).join('\n').trim(); } } } // Convert Obsidian-style wikilinks: [[note]] or [[note|display text]] // Must be done before marked.parse() to avoid conflicts with markdown syntax const notes = this.notes; // Reference for closure contentToRender = contentToRender.replace( /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, target, displayText) => { const linkTarget = target.trim(); const linkText = displayText ? displayText.trim() : linkTarget; const linkTargetLower = linkTarget.toLowerCase(); // Check if note exists (by path or by name, case-insensitive) const noteExists = notes.some(n => { const pathLower = n.path.toLowerCase(); const nameLower = n.name.toLowerCase(); return ( // Exact path match n.path === linkTarget || n.path === linkTarget + '.md' || // Case-insensitive path match pathLower === linkTargetLower || pathLower === linkTargetLower + '.md' || // Name match (with or without .md) n.name === linkTarget || n.name === linkTarget + '.md' || nameLower === linkTargetLower || nameLower === linkTargetLower + '.md' || // Match by filename at end of path n.path.endsWith('/' + linkTarget) || n.path.endsWith('/' + linkTarget + '.md') || pathLower.endsWith('/' + linkTargetLower) || pathLower.endsWith('/' + linkTargetLower + '.md') ); }); // Escape special chars: href needs quote escaping, text needs HTML escaping const safeHref = linkTarget.replace(/"/g, '%22'); const safeText = linkText.replace(/&/g, '&').replace(//g, '>'); // Return link with data attribute for styling broken links const brokenClass = noteExists ? '' : ' class="wikilink-broken"'; return `${safeText}`; } ); // Configure marked with syntax highlighting marked.setOptions({ breaks: true, gfm: true, highlight: function(code, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(code, { language: lang }).value; } catch (err) { console.error('Highlight error:', err); } } return hljs.highlightAuto(code).value; } }); // Parse markdown let html = marked.parse(contentToRender); // Post-process: Add target="_blank" to external links and title attributes to images // Parse as DOM to safely manipulate const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // Find all links const links = tempDiv.querySelectorAll('a'); links.forEach(link => { const href = link.getAttribute('href'); if (href && typeof href === 'string') { // Check if it's an external link const isExternal = href.indexOf('http://') === 0 || href.indexOf('https://') === 0 || href.indexOf('//') === 0; if (isExternal) { link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener noreferrer'); } } }); // Find all images and add title attribute from alt text for hover tooltips const images = tempDiv.querySelectorAll('img'); images.forEach(img => { const altText = img.getAttribute('alt'); if (altText) { // Set title attribute to show alt text on hover img.setAttribute('title', altText); } }); html = tempDiv.innerHTML; // Trigger MathJax rendering after DOM updates this.typesetMath(); // Render Mermaid diagrams this.renderMermaid(); // Apply syntax highlighting and add copy buttons to code blocks setTimeout(() => { // Use cached reference if available, otherwise query const previewEl = this._domCache.previewContent || document.querySelector('.markdown-preview'); if (previewEl) { // Exclude code blocks that are rendered by other tools (e.g., Mermaid diagrams) // Note: MathJax uses $$...$$ delimiters (not code blocks) so no exclusion needed previewEl.querySelectorAll('pre code:not(.language-mermaid)').forEach((block) => { // Apply syntax highlighting if (!block.classList.contains('hljs')) { hljs.highlightElement(block); } // Add copy button if not already present const pre = block.parentElement; if (pre && !pre.querySelector('.copy-code-button')) { this.addCopyButtonToCodeBlock(pre); } }); } }, 0); return html; }, // Refresh DOM element cache refreshDOMCache() { this._domCache.editor = document.querySelector('.editor-textarea'); this._domCache.previewContent = document.querySelector('.markdown-preview'); this._domCache.previewContainer = this._domCache.previewContent ? this._domCache.previewContent.parentElement : null; }, // Add copy button to code block addCopyButtonToCodeBlock(preElement) { // Create copy button const button = document.createElement('button'); button.className = 'copy-code-button'; button.innerHTML = ` `; button.title = 'Copy to clipboard'; // Style the button button.style.position = 'absolute'; button.style.top = '8px'; button.style.right = '8px'; button.style.padding = '6px'; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; button.style.border = 'none'; button.style.borderRadius = '4px'; button.style.cursor = 'pointer'; button.style.opacity = '0'; button.style.transition = 'opacity 0.2s, background-color 0.2s'; button.style.color = 'white'; button.style.display = 'flex'; button.style.alignItems = 'center'; button.style.justifyContent = 'center'; button.style.zIndex = '10'; // Style the pre element to be relative preElement.style.position = 'relative'; // Show button on hover preElement.addEventListener('mouseenter', () => { button.style.opacity = '1'; }); preElement.addEventListener('mouseleave', () => { button.style.opacity = '0'; }); // Copy to clipboard on click button.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const codeElement = preElement.querySelector('code'); if (!codeElement) return; const code = codeElement.textContent; try { await navigator.clipboard.writeText(code); // Visual feedback - change icon to checkmark button.innerHTML = ` `; button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; button.title = 'Copied!'; // Reset after 2 seconds setTimeout(() => { button.innerHTML = ` `; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; button.title = 'Copy to clipboard'; }, 2000); } catch (err) { console.error('Failed to copy code:', err); // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = code; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; document.body.appendChild(textArea); textArea.select(); try { document.execCommand('copy'); button.innerHTML = ` `; button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; setTimeout(() => { button.innerHTML = ` `; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; }, 2000); } catch (fallbackErr) { console.error('Fallback copy failed:', fallbackErr); } document.body.removeChild(textArea); } }); // Add button to pre element preElement.appendChild(button); }, // Setup scroll synchronization setupScrollSync() { // Use cached references (refresh if not available) if (!this._domCache.editor || !this._domCache.previewContainer) { this.refreshDOMCache(); } const editor = this._domCache.editor; const preview = this._domCache.previewContainer; if (!editor || !preview) { // If elements don't exist yet, retry with limit if (!this._setupScrollSyncRetries) this._setupScrollSyncRetries = 0; if (this._setupScrollSyncRetries < CONFIG.SCROLL_SYNC_MAX_RETRIES) { this._setupScrollSyncRetries++; setTimeout(() => this.setupScrollSync(), CONFIG.SCROLL_SYNC_RETRY_INTERVAL); } else { console.warn(`setupScrollSync: Failed to find editor/preview elements after ${CONFIG.SCROLL_SYNC_MAX_RETRIES} retries`); } return; } // Reset retry counter on success this._setupScrollSyncRetries = 0; // Remove old listeners if they exist if (this._editorScrollHandler) { editor.removeEventListener('scroll', this._editorScrollHandler); } if (this._previewScrollHandler) { preview.removeEventListener('scroll', this._previewScrollHandler); } // Create new scroll handlers this._editorScrollHandler = () => { if (this.isScrolling) { this.isScrolling = false; return; } const scrollableHeight = editor.scrollHeight - editor.clientHeight; if (scrollableHeight <= 0) return; // No scrolling needed const scrollPercentage = editor.scrollTop / scrollableHeight; const previewScrollableHeight = preview.scrollHeight - preview.clientHeight; if (previewScrollableHeight > 0) { this.isScrolling = true; preview.scrollTop = scrollPercentage * previewScrollableHeight; } }; this._previewScrollHandler = () => { if (this.isScrolling) { this.isScrolling = false; return; } const scrollableHeight = preview.scrollHeight - preview.clientHeight; if (scrollableHeight <= 0) return; // No scrolling needed const scrollPercentage = preview.scrollTop / scrollableHeight; const editorScrollableHeight = editor.scrollHeight - editor.clientHeight; if (editorScrollableHeight > 0) { this.isScrolling = true; editor.scrollTop = scrollPercentage * editorScrollableHeight; } }; // Attach new listeners editor.addEventListener('scroll', this._editorScrollHandler); preview.addEventListener('scroll', this._previewScrollHandler); }, // Check if stats plugin is enabled async checkStatsPlugin() { try { const response = await fetch('/api/plugins'); const data = await response.json(); const statsPlugin = data.plugins.find(p => p.id === 'note_stats'); this.statsPluginEnabled = statsPlugin && statsPlugin.enabled; // Calculate stats for current note if enabled if (this.statsPluginEnabled && this.noteContent) { this.calculateStats(); } } catch (error) { console.error('Failed to check stats plugin:', error); this.statsPluginEnabled = false; } }, // Calculate note statistics (client-side) calculateStats() { if (!this.statsPluginEnabled || !this.noteContent) { this.noteStats = null; return; } const content = this.noteContent; // Word count const words = (content.match(/\S+/g) || []).length; // Character count const chars = content.replace(/\s/g, '').length; const totalChars = content.length; // Reading time (200 words per minute) const readingTime = Math.max(1, Math.round(words / 200)); // Line count const lines = content.split('\n').length; // Paragraph count const paragraphs = content.split('\n\n').filter(p => p.trim()).length; // Sentences: punctuation [.!?]+ followed by space or end-of-string const sentences = (content.match(/[.!?]+(?:\s|$)/g) || []).length; // List items: lines starting with -, *, + or a number (e.g. 1., 10.), excluding tasks [-] const listItems = (content.match(/^\s*(?:[-*+]|\d+\.)\s+(?!\[)/gm) || []).length; // Tables: markdown table separator rows (| --- | --- |) const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length; // Link count (standard markdown links) const markdownLinkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || []; const markdownLinks = markdownLinkMatches.length; const markdownInternalLinks = markdownLinkMatches.filter(l => l.includes('.md')).length; // Wikilink count ([[note]] or [[note|display text]] format) const wikilinks = (content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g) || []).length; // Total links (markdown + wikilinks) const links = markdownLinks + wikilinks; const internalLinks = markdownInternalLinks + wikilinks; // All wikilinks are internal // Code blocks const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length; const inlineCode = (content.match(/`[^`]+`/g) || []).length; // Headings const h1 = (content.match(/^# /gm) || []).length; const h2 = (content.match(/^## /gm) || []).length; const h3 = (content.match(/^### /gm) || []).length; // Tasks const totalTasks = (content.match(/- \[[ x]\]/gi) || []).length; const completedTasks = (content.match(/- \[x\]/gi) || []).length; const pendingTasks = totalTasks - completedTasks; const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; // Images const images = (content.match(/!\[([^\]]*)\]\(([^\)]+)\)/g) || []).length; // Blockquotes const blockquotes = (content.match(/^> /gm) || []).length; this.noteStats = { words, sentences, characters: chars, total_characters: totalChars, reading_time_minutes: readingTime, lines, paragraphs, list_items: listItems, tables, links, internal_links: internalLinks, external_links: links - internalLinks, wikilinks, code_blocks: codeBlocks, inline_code: inlineCode, headings: { h1, h2, h3, total: h1 + h2 + h3 }, tasks: { total: totalTasks, completed: completedTasks, pending: pendingTasks, completion_rate: completionRate }, images, blockquotes }; }, // Parse YAML frontmatter metadata from note content parseMetadata() { if (!this.noteContent) { this.noteMetadata = null; this._lastFrontmatter = null; return; } const content = this.noteContent; // Check if content starts with frontmatter if (!content.trim().startsWith('---')) { this.noteMetadata = null; this._lastFrontmatter = null; return; } try { const lines = content.split('\n'); if (lines[0].trim() !== '---') { this.noteMetadata = null; this._lastFrontmatter = null; return; } // Find closing --- let endIdx = -1; for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { endIdx = i; break; } } if (endIdx === -1) { this.noteMetadata = null; this._lastFrontmatter = null; return; } // Performance optimization: skip parsing if frontmatter unchanged const frontmatterRaw = lines.slice(0, endIdx + 1).join('\n'); if (frontmatterRaw === this._lastFrontmatter) { return; // No change, keep existing metadata } this._lastFrontmatter = frontmatterRaw; const frontmatterLines = lines.slice(1, endIdx); const metadata = {}; let currentKey = null; let currentValue = []; for (const line of frontmatterLines) { // Check for new key: value pair (supports keys with hyphens/underscores) const keyMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/); if (keyMatch) { // Save previous key if exists if (currentKey) { metadata[currentKey] = this.parseYamlValue(currentValue.join('\n')); } currentKey = keyMatch[1]; const value = keyMatch[2].trim(); currentValue = [value]; } else if (line.match(/^\s+-\s+/) && currentKey) { // List item continuation (e.g., " - item") currentValue.push(line); } else if (line.startsWith(' ') && currentKey) { // Indented content (multiline value) currentValue.push(line); } } // Save last key if (currentKey) { metadata[currentKey] = this.parseYamlValue(currentValue.join('\n')); } this.noteMetadata = Object.keys(metadata).length > 0 ? metadata : null; } catch (error) { console.error('Failed to parse frontmatter:', error); this.noteMetadata = null; this._lastFrontmatter = null; } }, // Parse a YAML value (handles arrays, strings, numbers, booleans) parseYamlValue(value) { if (!value || value.trim() === '') return null; value = value.trim(); // Check for inline array: [item1, item2] if (value.startsWith('[') && value.endsWith(']')) { const inner = value.slice(1, -1); return inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(s => s); } // Check for YAML list format (multiple lines starting with -) if (value.includes('\n -') || value.startsWith(' -')) { const items = []; const lines = value.split('\n'); for (const line of lines) { const match = line.match(/^\s*-\s*(.+)$/); if (match) { items.push(match[1].trim().replace(/^["']|["']$/g, '')); } } return items.length > 0 ? items : value; } // Check for boolean if (value.toLowerCase() === 'true') return true; if (value.toLowerCase() === 'false') return false; // Check for number if (/^-?\d+(\.\d+)?$/.test(value)) { return parseFloat(value); } // Return as string (remove quotes if present) return value.replace(/^["']|["']$/g, ''); }, // Check if a string is a URL isUrl(str) { if (typeof str !== 'string') return false; return /^https?:\/\/\S+$/i.test(str.trim()); }, // Escape HTML to prevent XSS escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }, // Format metadata value for display formatMetadataValue(key, value) { if (value === null || value === undefined) return ''; // Arrays are handled separately in the template if (Array.isArray(value)) return value; // Format dates nicely if (key === 'date' || key === 'created' || key === 'modified' || key === 'updated') { const date = new Date(value); if (!isNaN(date.getTime())) { return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } } // Booleans if (typeof value === 'boolean') { return value ? '✓ Yes' : '✗ No'; } return String(value); }, // Format metadata value as HTML (for URL support) formatMetadataValueHtml(key, value) { const formatted = this.formatMetadataValue(key, value); // Check if it's a URL if (this.isUrl(formatted)) { const escaped = this.escapeHtml(formatted); // Truncate long URLs for display const displayUrl = formatted.length > 40 ? formatted.substring(0, 37) + '...' : formatted; return `${this.escapeHtml(displayUrl)}`; } return this.escapeHtml(formatted); }, // Get priority metadata fields (shown in collapsed view) getPriorityMetadataFields() { if (!this.noteMetadata) return []; // Fields to show in collapsed view, in order of priority const priority = ['date', 'created', 'author', 'status', 'priority', 'type', 'category']; const fields = []; for (const key of priority) { if (this.noteMetadata[key] !== undefined && !Array.isArray(this.noteMetadata[key])) { const formatted = this.formatMetadataValue(key, this.noteMetadata[key]); const isUrl = this.isUrl(formatted); fields.push({ key, value: formatted, valueHtml: isUrl ? this.formatMetadataValueHtml(key, this.noteMetadata[key]) : this.escapeHtml(formatted), isUrl }); } } return fields.slice(0, 3); // Show max 3 fields in collapsed view }, // Get all metadata fields except tags (for expanded view) getAllMetadataFields() { if (!this.noteMetadata) return []; return Object.entries(this.noteMetadata) .filter(([key]) => key !== 'tags') // Tags shown separately .map(([key, value]) => { const isArray = Array.isArray(value); const formatted = this.formatMetadataValue(key, value); const isUrl = !isArray && this.isUrl(formatted); return { key, value: formatted, valueHtml: isUrl ? this.formatMetadataValueHtml(key, value) : this.escapeHtml(formatted), isArray, isUrl }; }); }, // Check if note has any displayable metadata getHasMetadata() { const has = this.noteMetadata && Object.keys(this.noteMetadata).length > 0; return has; }, // Get tags from metadata getMetadataTags() { if (!this.noteMetadata || !this.noteMetadata.tags) return []; return Array.isArray(this.noteMetadata.tags) ? this.noteMetadata.tags : [this.noteMetadata.tags]; }, // Load sidebar width from localStorage loadSidebarWidth() { const saved = localStorage.getItem('sidebarWidth'); if (saved) { const width = parseInt(saved, 10); if (width >= 200 && width <= 600) { this.sidebarWidth = width; } } }, // Save sidebar width to localStorage saveSidebarWidth() { localStorage.setItem('sidebarWidth', this.sidebarWidth.toString()); }, // Load view mode from localStorage loadViewMode() { try { const saved = localStorage.getItem('viewMode'); if (saved && ['edit', 'split', 'preview'].includes(saved)) { this.viewMode = saved; } } catch (error) { console.error('Error loading view mode:', error); } }, // Save view mode to localStorage saveViewMode() { try { localStorage.setItem('viewMode', this.viewMode); } catch (error) { console.error('Error saving view mode:', error); } }, loadTagsExpanded() { try { const saved = localStorage.getItem('tagsExpanded'); if (saved !== null) { this.tagsExpanded = saved === 'true'; } } catch (error) { console.error('Error loading tags expanded state:', error); } }, saveTagsExpanded() { try { localStorage.setItem('tagsExpanded', this.tagsExpanded.toString()); } catch (error) { console.error('Error saving tags expanded state:', error); } }, // Start resizing sidebar startResize(event) { this.isResizing = true; event.preventDefault(); const resize = (e) => { if (!this.isResizing) return; // Calculate new width based on mouse position const newWidth = e.clientX; // Clamp between min and max if (newWidth >= 200 && newWidth <= 600) { this.sidebarWidth = newWidth; } }; const stopResize = () => { if (this.isResizing) { this.isResizing = false; this.saveSidebarWidth(); document.removeEventListener('mousemove', resize); document.removeEventListener('mouseup', stopResize); } }; document.addEventListener('mousemove', resize); document.addEventListener('mouseup', stopResize); }, // Start resizing split panes (editor/preview) startSplitResize(event) { this.isResizingSplit = true; event.preventDefault(); const container = event.target.parentElement; const resize = (e) => { if (!this.isResizingSplit) return; const containerRect = container.getBoundingClientRect(); const mouseX = e.clientX - containerRect.left; const percentage = (mouseX / containerRect.width) * 100; // Clamp between 20% and 80% if (percentage >= 20 && percentage <= 80) { this.editorWidth = percentage; } }; const stopResize = () => { if (this.isResizingSplit) { this.isResizingSplit = false; this.saveEditorWidth(); document.removeEventListener('mousemove', resize); document.removeEventListener('mouseup', stopResize); } }; document.addEventListener('mousemove', resize); document.addEventListener('mouseup', stopResize); }, // Setup mobile view mode handler (auto-switch from split to edit on mobile) setupMobileViewMode() { const MOBILE_BREAKPOINT = 768; // Match CSS breakpoint let previousWidth = window.innerWidth; const handleResize = () => { const currentWidth = window.innerWidth; const wasMobile = previousWidth <= MOBILE_BREAKPOINT; const isMobile = currentWidth <= MOBILE_BREAKPOINT; // If switching from desktop to mobile and in split mode if (!wasMobile && isMobile && this.viewMode === 'split') { this.viewMode = 'edit'; } previousWidth = currentWidth; }; // Listen for window resize window.addEventListener('resize', handleResize); // Check initial state if (window.innerWidth <= MOBILE_BREAKPOINT && this.viewMode === 'split') { this.viewMode = 'edit'; } }, // Load editor width from localStorage loadEditorWidth() { const saved = localStorage.getItem('editorWidth'); if (saved) { const width = parseFloat(saved); if (width >= 20 && width <= 80) { this.editorWidth = width; } } }, // Save editor width to localStorage saveEditorWidth() { localStorage.setItem('editorWidth', this.editorWidth.toString()); }, // Scroll to top of editor and preview scrollToTop() { // Disable scroll sync temporarily to prevent interference this.isScrolling = true; // Use cached references (refresh if not available) if (!this._domCache.editor || !this._domCache.previewContainer) { this.refreshDOMCache(); } // Only scroll the visible panes based on viewMode if (this.viewMode === 'edit' || this.viewMode === 'split') { if (this._domCache.editor) { this._domCache.editor.scrollTop = 0; } } if (this.viewMode === 'preview' || this.viewMode === 'split') { // Scroll the preview container (parent of .markdown-preview) if (this._domCache.previewContainer) { this._domCache.previewContainer.scrollTop = 0; } } // Re-enable scroll sync after a short delay setTimeout(() => { this.isScrolling = false; }, CONFIG.SCROLL_SYNC_DELAY); }, // Export current note as HTML async exportToHTML() { if (!this.currentNote || !this.noteContent) { alert('No note content to export'); return; } 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) const renderedHTML = this.renderedMarkdown; // Get current theme CSS const currentTheme = this.currentTheme || 'light'; const themeResponse = await fetch(`/api/themes/${currentTheme}`); const themeText = await themeResponse.text(); // 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; } // 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); } } // Create standalone HTML document with MathJax const htmlDocument = `