// NoteDiscovery Frontend Application // Configuration constants const CONFIG = { AUTOSAVE_DELAY: 1000, // ms - Debounce before note save (autoSave) and drawing PNG autosave (_drawingScheduleAutosave) /** Must match drawingRedraw() fill and eraser stroke color (opaque β€œwhiteboard”). */ DRAWING_BACKGROUND: '#ffffff', /** * Drawing document size (intrinsic resolution). The display canvas may render * smaller on small screens, but pointer events, ops and the saved PNG always * live in this fixed coordinate space. A future "new drawing" dialog can * override the dimensions per drawing without any architectural change. */ DRAWING_DEFAULT_DOC_W: 1200, DRAWING_DEFAULT_DOC_H: 800, /** Hard bounds applied when (re)loading or creating a drawing. */ DRAWING_MIN_DOC_DIM: 64, DRAWING_MAX_DOC_DIM: 4096, SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing 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) TOAST_MAX_VISIBLE: 4, // Max stacked toasts; oldest dropped when exceeded TOAST_DURATION_ERROR_MS: 7000, TOAST_DURATION_WARNING_MS: 5500, TOAST_DURATION_INFO_MS: 4500, TOAST_DURATION_SUCCESS_MS: 3500, }; /** Heroicons outline "share" (same d= as shared-note icon in the file tree) */ const SHARE_ICON_PATH = 'M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z'; // localStorage settings configuration - centralized definition of all persisted settings const LOCAL_SETTINGS = { // Boolean settings syntaxHighlightEnabled: { key: 'syntaxHighlightEnabled', type: 'boolean', default: false }, readableLineLength: { key: 'readableLineLength', type: 'boolean', default: true }, 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 }, // String settings sortMode: { key: 'sortMode', type: 'string', default: 'a-z' }, // 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 }, // String settings with validation viewMode: { key: 'viewMode', type: 'string', default: 'split', valid: ['edit', 'split', 'preview'] }, // JSON settings favorites: { key: 'noteFavorites', type: 'json', default: [] }, }; // 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} notify - Whether to show a toast (listens on notediscovery:toast) */ handle(operation, error, notify = true) { console.error(`Failed to ${operation}:`, error); if (notify) { const message = `Failed to ${operation}. Please try again.`; window.dispatchEvent(new CustomEvent('notediscovery:toast', { detail: { message, type: 'error' } })); } } }; /** * Centralized filename validation * Supports Unicode characters (international text) but blocks dangerous filesystem characters. * Does NOT silently modify filenames - validates and returns status. */ const FilenameValidator = { // Characters that are forbidden in filenames across Windows/macOS/Linux // Windows: \ / : * ? " < > | // macOS: / : // Linux: / \0 // Common set to block (including control characters) FORBIDDEN_CHARS: /[\\/:*?"<>|\x00-\x1f]/, // For display purposes - human readable list FORBIDDEN_CHARS_DISPLAY: '\\ / : * ? " < > |', /** * Validate a filename (single segment, no path separators) * @param {string} name - The filename to validate * @returns {{ valid: boolean, error?: string, sanitized?: string }} */ validateFilename(name) { if (!name || typeof name !== 'string') { return { valid: false, error: 'empty' }; } const trimmed = name.trim(); if (!trimmed) { return { valid: false, error: 'empty' }; } // Check for forbidden characters if (this.FORBIDDEN_CHARS.test(trimmed)) { return { valid: false, error: 'forbidden_chars', forbiddenChars: this.FORBIDDEN_CHARS_DISPLAY }; } // Check for reserved Windows names (case-insensitive) const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; if (reservedNames.test(trimmed)) { return { valid: false, error: 'reserved_name' }; } // Check for names starting/ending with dots or spaces (problematic on some systems) if (trimmed.startsWith('.') && trimmed.length === 1) { return { valid: false, error: 'invalid_dot' }; } if (trimmed.endsWith('.') || trimmed.endsWith(' ')) { return { valid: false, error: 'trailing_dot_space' }; } return { valid: true, sanitized: trimmed }; }, /** * Validate a path (may contain forward slashes for folder separators) * @param {string} path - The path to validate * @returns {{ valid: boolean, error?: string, sanitized?: string }} */ validatePath(path) { if (!path || typeof path !== 'string') { return { valid: false, error: 'empty' }; } const trimmed = path.trim(); if (!trimmed) { return { valid: false, error: 'empty' }; } // Split by forward slash and validate each segment const segments = trimmed.split('/').filter(s => s.length > 0); if (segments.length === 0) { return { valid: false, error: 'empty' }; } for (const segment of segments) { const result = this.validateFilename(segment); if (!result.valid) { return result; } } // Rebuild path without empty segments return { valid: true, sanitized: segments.join('/') }; } }; function noteApp() { return { // App state appName: 'NoteDiscovery', appVersion: '0.0.0', authEnabled: false, demoMode: false, alreadyDonated: 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, linkCopied: false, zenMode: false, previousViewMode: 'split', favorites: [], favoritesSet: new Set(), // For O(1) lookups favoritesExpanded: true, saveTimeout: null, // Note lookup maps for O(1) wikilink resolution (built on loadNotes) _noteLookup: { byPath: new Map(), // path -> true byPathLower: new Map(), // path.toLowerCase() -> true byName: new Map(), // name (without .md) -> true byNameLower: new Map(), // name.toLowerCase() -> true byEndPath: new Map(), // '/filename' and '/filename.md' -> true }, // Media lookup map for O(1) media wikilink resolution (built on loadNotes) // Maps media filename (case-insensitive) -> full path _mediaLookup: new Map(), // Preview rendering debounce _previewDebounceTimeout: null, _lastRenderedContent: '', _lastRenderedNote: '', _cachedRenderedHTML: '', _mathDebounceTimeout: null, _mermaidDebounceTimeout: null, // Theme state currentTheme: 'light', availableThemes: [], // Locale/i18n state currentLocale: localStorage.getItem('locale') || 'en-US', availableLocales: [], // Translations loaded from backend (preloaded before Alpine init via window.__preloadedTranslations) translations: window.__preloadedTranslations || {}, // Syntax highlighting syntaxHighlightEnabled: false, syntaxHighlightTimeout: null, // Readable line length (preview max-width) readableLineLength: true, // 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', // Note sorting mode (a-z, z-a, newest, oldest, largest, smallest) sortMode: localStorage.getItem('sortMode') || 'a-z', // Icon rail / panel state activePanel: 'files', // 'files', 'search', 'tags', 'outline', 'backlinks', 'shared', 'settings' // Folder state folderTree: [], allFolders: [], expandedFolders: new Set(), dragOverFolder: null, // Track which folder is being hovered during drag // Tags state allTags: {}, selectedTags: [], tagsExpanded: false, tagReloadTimeout: null, // For debouncing tag reloads // Search state searchDebounceTimeout: null, isSearching: false, // Outline (TOC) state outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...] // Backlinks state backlinks: [], // [{path: 'note.md', name: 'Note', references: [{line_number: 5, context: '...', type: 'wikilink'}]}] // Scroll sync state isScrolling: false, // Unified drag state for notes, folders, and media draggedItem: null, // { path: string, type: 'note' | 'folder' | 'image' | 'audio' | 'video' | 'document' } dropTarget: null, // 'editor' | 'folder' | null // Undo/Redo history undoHistory: [], redoHistory: [], maxHistorySize: CONFIG.MAX_UNDO_HISTORY, isUndoRedo: false, hasPendingHistoryChanges: 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: '', // New note / folder name modal (replaces window.prompt) showCreateNameModal: false, createNameModalKind: 'note', createNameModalTargetFolder: '', createNameModalInput: '', // Rename folder modal (replaces window.prompt) showRenameFolderModal: false, renameFolderPath: '', renameFolderOldName: '', renameFolderInput: '', // Generic confirm dialog (replaces window.confirm) showConfirmModal: false, confirmModalTitle: '', confirmModalMessage: '', confirmModalDanger: true, confirmModalConfirmLabel: '', confirmModalCancelLabel: '', _confirmModalResolve: null, // Share state showShareModal: false, shareInfo: null, shareLoading: false, showShareQR: false, shareLinkCopied: false, _sharedNotePaths: new Set(), // O(1) lookup for shared note indicators _sharedNotePathsList: [], // sorted paths, mirrors Set for reactive sidebar panel // Quick Switcher state (Ctrl+Alt+P) showQuickSwitcher: false, quickSwitcherQuery: '', quickSwitcherIndex: 0, quickSwitcherResults: [], // Non-blocking notifications (replaces window.alert) toasts: [], _toastIdSeq: 0, // 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: this.t('homepage.title'), 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]; }, // Helper: Format date using current locale formatDate(dateStr) { if (!dateStr) return ''; const date = new Date(dateStr); if (isNaN(date.getTime())) return ''; return date.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' }); }, 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, // Media viewer state currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility) currentMediaType: 'image', // 'image', 'audio', 'video', 'document', 'drawing' // Drawing canvas (drawing-*.png only) β€” ops are session-only until Save flattens to PNG. // Coordinates in drawingOps[] are stored in DOCUMENT space (drawingDocW Γ— drawingDocH), // not display CSS pixels, so resizing the editor pane never moves strokes and exported // PNGs are byte-deterministic regardless of screen size or device pixel ratio. drawingTool: 'freehand', drawingColor: '#1a1a1a', drawingLineWidth: 4, drawingOps: [], /** * Intrinsic drawing dimensions (in document px). Initial values come from CONFIG so * there is a single source of truth for the default; per-drawing values get overwritten * by createNewDrawing() (using its opts) or initDrawingViewer() (using the PNG's natural * size). To change the default, edit CONFIG.DRAWING_DEFAULT_DOC_W / _H only. */ drawingDocW: CONFIG.DRAWING_DEFAULT_DOC_W, drawingDocH: CONFIG.DRAWING_DEFAULT_DOC_H, drawingRedoStack: [], drawingDraft: null, drawingIsPointerDown: false, /** True after the PNG from disk has been decoded into _drawingBaseImage; false after Clear. */ drawingHasRasterFromFile: false, /** Prevents overlapping drawingSave() runs (Ctrl+S + autosave + fast retries). */ _drawingSaveInFlight: false, /** If true, run drawingSave again after the current one finishes (coalesce). */ _drawingSaveQueued: false, _drawingAutosaveTimeout: null, // DOM element cache (to avoid repeated querySelector calls) _domCache: { editor: null, previewContainer: null, previewContent: null }, // Initialize app async init() { // Prevent double initialization (Alpine.js may call x-init twice in some cases) if (window.__noteapp_initialized) return; window.__noteapp_initialized = true; // 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.draggedItem) { this.cancelDrag(); } }); await this.loadConfig(); await this.loadThemes(); await this.initTheme(); await this.loadAvailableLocales(); // Note: Translations are preloaded synchronously before Alpine init (see index.html) // loadLocale() is only called when user changes language from settings await this.loadNotes(); await this.loadSharedNotePaths(); await this.loadTemplates(); await this.checkStatsPlugin(); this.loadLocalSettings(); // Parse URL and load specific note if provided this.loadItemFromURL(); // Set initial homepage state ONLY if we're actually on the homepage if (window.location.pathname === '/') { window.history.replaceState({ homepageFolder: '' }, '', '/'); document.title = this.appName; } // 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 if (e.state && e.state.mediaPath) { // Navigating to a media file this.viewMedia(e.state.mediaPath, null, false); } else { // Navigating back to homepage this.currentNote = ''; this.noteContent = ''; this.currentNoteName = ''; this.outline = []; this.backlinks = []; this.shareInfo = null; // Reset share info document.title = this.appName; // 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(); }); // Watch favorites expanded state and save to localStorage this.$watch('favoritesExpanded', () => { this.saveFavoritesExpanded(); }); // Setup keyboard shortcuts (only once to prevent double triggers) if (!window.__noteapp_shortcuts_initialized) { window.__noteapp_shortcuts_initialized = true; window.addEventListener('keydown', (e) => { // Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts // Ctrl/Cmd + S to save (drawing saves PNG; notes save markdown) if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { if (this.currentMedia && this.currentMediaType === 'drawing') { e.preventDefault(); this.drawingSave(); } else { e.preventDefault(); this.saveNote(); } } // Ctrl/Cmd + Alt + P for Quick Switcher if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'p') { e.preventDefault(); this.openQuickSwitcher(); return; } // Ctrl/Cmd + Alt/Option + N for new note if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'n') { e.preventDefault(); this.createNote(); } // Ctrl/Cmd + Alt/Option + F for new folder if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'f') { e.preventDefault(); this.createFolder(); } // Ctrl/Cmd + Z for undo (drawing vs note editor) if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { if (this.currentMedia && this.currentMediaType === 'drawing') { e.preventDefault(); this.drawingUndo(); } else { e.preventDefault(); this.undo(); } } // Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { if (this.currentMedia && this.currentMediaType === 'drawing') { e.preventDefault(); this.drawingRedo(); } else { e.preventDefault(); this.redo(); } } if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { if (this.currentMedia && this.currentMediaType === 'drawing') { e.preventDefault(); this.drawingRedo(); } else { e.preventDefault(); this.redo(); } } // F3 for next search match if (e.code === 'F3' && !e.shiftKey) { e.preventDefault(); this.nextMatch(); } // Shift + F3 for previous search match if (e.code === '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.toLowerCase() === 'b') { e.preventDefault(); this.wrapSelection('**', '**', 'bold text'); } // Ctrl/Cmd + I for italic if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i') { e.preventDefault(); this.wrapSelection('*', '*', 'italic text'); } // Ctrl/Cmd + K for link if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); this.insertLink(); } // Ctrl/Cmd + Alt/Option + T for table if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 't') { e.preventDefault(); this.insertTable(); } // Ctrl/Cmd + Alt/Option + Z for Zen mode if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'z') { e.preventDefault(); this.toggleZenMode(); } } // Escape to exit Zen mode (works anywhere) if (e.key === 'Escape' && this.zenMode) { e.preventDefault(); this.toggleZenMode(); } }); } // 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'); } }); } // Listen for fullscreen changes (to sync zen mode state) document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && this.zenMode) { // User exited fullscreen manually, exit zen mode too this.zenMode = false; this.viewMode = this.previousViewMode; } }); // Toasts from ErrorHandler and code paths without Alpine `this` if (!window.__noteapp_toast_listener) { window.__noteapp_toast_listener = true; window.addEventListener('notediscovery:toast', (e) => { const d = e.detail || {}; if (typeof d.message !== 'string' || !d.message) return; this.toast(d.message, { type: d.type || 'error', durationMs: d.durationMs }); }); } }, // Load app configuration async loadConfig() { try { const response = await fetch('/api/config'); const config = await response.json(); this.appName = config.name; this.appVersion = config.version || '0.0.0'; this.authEnabled = config.authentication?.enabled || false; this.demoMode = config.demoMode || false; this.alreadyDonated = config.alreadyDonated || 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(); } }, // Load all localStorage settings at once using centralized config loadLocalSettings() { for (const [prop, config] of Object.entries(LOCAL_SETTINGS)) { try { const saved = localStorage.getItem(config.key); if (saved === null) { // Use default value if not set this[prop] = config.default; } else if (config.type === 'boolean') { this[prop] = saved === 'true'; } else if (config.type === 'number') { const num = parseFloat(saved); // Validate range if specified if (!isNaN(num) && (config.min === undefined || num >= config.min) && (config.max === undefined || num <= config.max)) { this[prop] = num; } else { this[prop] = config.default; } } else if (config.type === 'string') { // Validate against allowed values if specified if (!config.valid || config.valid.includes(saved)) { this[prop] = saved; } else { this[prop] = config.default; } } else if (config.type === 'json') { this[prop] = JSON.parse(saved); } } catch (error) { console.error(`Error loading setting ${prop}:`, error); this[prop] = config.default; } } // Special case: favorites also needs to update the Set for O(1) lookups this.favoritesSet = new Set(this.favorites); }, // Readable line length toggle (for preview max-width) toggleReadableLineLength() { this.readableLineLength = !this.readableLineLength; localStorage.setItem('readableLineLength', this.readableLineLength); }, // Hide underscore folders toggle (hides _attachments, _templates, etc. from sidebar) toggleHideUnderscoreFolders() { 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(); }, /** * Enter: continue blockquote / bullet / ordered list / task list; second Enter on empty item exits (see editor-markdown-continue.js). */ handleEditorEnterKey(event) { if (typeof EditorMarkdownContinue === 'undefined') return; const textarea = event.target; if (!textarea || textarea.id !== 'note-editor') return; const result = EditorMarkdownContinue.tryEnter( this.noteContent, textarea.selectionStart, textarea.selectionEnd, event ); if (!result.handled) return; event.preventDefault(); this.noteContent = result.text; this.$nextTick(() => { textarea.selectionStart = textarea.selectionEnd = result.cursor; }); this.autoSave(); this.updateSyntaxHighlight(); }, // Sort mode configuration sortModes: ['a-z', 'z-a', 'newest', 'oldest', 'largest', 'smallest'], sortModeIcons: { 'a-z': 'A↓', 'z-a': 'Z↓', 'newest': 'πŸ•β†“', 'oldest': 'πŸ•β†‘', 'largest': 'πŸ“„β†“', 'smallest': 'πŸ“„β†‘' }, // Cycle through sort modes cycleSortMode() { const currentIndex = this.sortModes.indexOf(this.sortMode); const nextIndex = (currentIndex + 1) % this.sortModes.length; this.sortMode = this.sortModes[nextIndex]; localStorage.setItem('sortMode', this.sortMode); // Rebuild tree to apply new sort order this.buildFolderTree(); }, // Get current sort icon getSortIcon() { return this.sortModeIcons[this.sortMode] || 'A↓'; }, // Get sort comparator based on current mode (for notes) getSortComparator() { switch (this.sortMode) { case 'z-a': return (a, b) => b.name.toLowerCase().localeCompare(a.name.toLowerCase()); case 'newest': return (a, b) => (b.modified || '').localeCompare(a.modified || ''); case 'oldest': return (a, b) => (a.modified || '').localeCompare(b.modified || ''); case 'largest': return (a, b) => (b.size || 0) - (a.size || 0); case 'smallest': return (a, b) => (a.size || 0) - (b.size || 0); case 'a-z': default: return (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()); } }, // Get sort comparator for folders (only A-Z/Z-A, others default to A-Z) getFolderSortComparator() { if (this.sortMode === 'z-a') { return (a, b) => b.name.toLowerCase().localeCompare(a.name.toLowerCase()); } // Default: A-Z for all other modes return (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }, // 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); // Store code blocks and inline code with placeholders to protect from other patterns const codePlaceholders = []; // Code blocks FIRST - protect them before anything else html = html.replace(/(```[\s\S]*?```)/g, (match) => { codePlaceholders.push('' + match + ''); return `\x00CODE${codePlaceholders.length - 1}\x00`; }); // Frontmatter (must be at VERY start of document, not any line) if (html.startsWith('---\n')) { html = html.replace(/^(---\n[\s\S]*?\n---)/, (match) => { codePlaceholders.push('' + match + ''); return `\x00CODE${codePlaceholders.length - 1}\x00`; }); } // Inline code - protect it html = html.replace(/`([^`\n]+)`/g, (match) => { codePlaceholders.push('' + match + ''); return `\x00CODE${codePlaceholders.length - 1}\x00`; }); // Now apply other patterns (they won't match inside protected code) // Headings - capture the whitespace to preserve exact characters (tabs vs spaces) // This prevents cursor/selection misalignment html = html.replace(/^(#{1,6})(\s)(.*)$/gm, '$1$2$3'); // Bold (must come before italic) html = html.replace(/\*\*([^*]+)\*\*/g, '**$1**'); html = html.replace(/__([^_]+)__/g, '__$1__'); // Italic html = html.replace(/(?*$1*'); html = html.replace(/(?_$1_'); // Wikilinks [[...]] html = html.replace(/\[\[([^\]]+)\]\]/g, '[[$1]]'); // Links [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1]($2)'); // Lists - use ([ \t]) to capture the space/tab and preserve exact characters // IMPORTANT: Don't add any characters (like \u200B) that aren't in the original, // as this breaks cursor/selection alignment between textarea and overlay html = html.replace(/^(\s*)([-*+])([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => { return `${indent}${bullet}${space}${rest}`; }); html = html.replace(/^(\s*)(\d+\.)([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => { return `${indent}${bullet}${space}${rest}`; }); // Blockquotes html = html.replace(/^(>.*)$/gm, '$1'); // Horizontal rules html = html.replace(/^([-*_]{3,})$/gm, '$1'); // Restore protected code blocks html = html.replace(/\x00CODE(\d+)\x00/g, (match, index) => codePlaceholders[parseInt(index)]); // Add trailing space to match textarea's phantom line for cursor // This ensures the overlay and textarea have the same content height html += '\n '; 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); } // Update PWA theme-color meta tag to match current theme const themeColorMeta = document.querySelector('meta[name="theme-color"]'); if (themeColorMeta) { // Get the accent color from CSS variables const accentColor = getComputedStyle(document.documentElement) .getPropertyValue('--accent-primary').trim() || '#667eea'; themeColorMeta.setAttribute('content', accentColor); } } catch (error) { console.error('Failed to load theme:', error); } }, // ==================== INTERNATIONALIZATION ==================== // Translation function - get translated string by key t(key, params = {}) { const keys = key.split('.'); let value = this.translations; for (const k of keys) { value = value?.[k]; } // Fallback to key if translation not found (silently - default translations are inline) if (typeof value !== 'string') { return key; } // Replace {{param}} placeholders return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`); }, /** * Non-blocking toast (replaces window.alert). Types map to theme CSS variables. * @param {string} message * @param {{ type?: 'error'|'warning'|'info'|'success', durationMs?: number }} [options] * @returns {number|undefined} toast id */ toast(message, options = {}) { if (typeof message !== 'string' || !message) return; const type = ['error', 'warning', 'info', 'success'].includes(options.type) ? options.type : 'info'; let durationMs = options.durationMs; if (durationMs == null || typeof durationMs !== 'number' || durationMs < 0) { durationMs = type === 'error' ? CONFIG.TOAST_DURATION_ERROR_MS : type === 'warning' ? CONFIG.TOAST_DURATION_WARNING_MS : type === 'success' ? CONFIG.TOAST_DURATION_SUCCESS_MS : CONFIG.TOAST_DURATION_INFO_MS; } const id = ++this._toastIdSeq; const entry = { id, message, type, _tid: null }; this.toasts.push(entry); while (this.toasts.length > CONFIG.TOAST_MAX_VISIBLE) { const first = this.toasts.shift(); if (first && first._tid) clearTimeout(first._tid); } entry._tid = setTimeout(() => this.dismissToast(id), durationMs); return id; }, dismissToast(id) { const idx = this.toasts.findIndex((t) => t.id === id); if (idx === -1) return; const entry = this.toasts[idx]; if (entry._tid) { clearTimeout(entry._tid); entry._tid = null; } this.toasts.splice(idx, 1); }, /** * Get localized error message from FilenameValidator result * @param {object} validation - The validation result from FilenameValidator * @param {string} type - 'note' or 'folder' * @returns {string} Localized error message */ getValidationErrorMessage(validation, type = 'note') { switch (validation.error) { case 'empty': return type === 'note' ? this.t('notes.empty_name') : this.t('folders.invalid_name'); case 'forbidden_chars': return this.t('validation.forbidden_chars', { chars: validation.forbiddenChars }); case 'reserved_name': return this.t('validation.reserved_name'); case 'invalid_dot': return this.t('validation.invalid_dot'); case 'trailing_dot_space': return this.t('validation.trailing_dot_space'); default: return type === 'note' ? this.t('notes.invalid_name') : this.t('folders.invalid_name'); } }, // Load available locales from backend async loadAvailableLocales() { try { const response = await fetch('/api/locales'); const data = await response.json(); this.availableLocales = data.locales || []; } catch (error) { console.error('Failed to load available locales:', error); this.availableLocales = [{ code: 'en-US', name: 'English', flag: 'πŸ‡ΊπŸ‡Έ' }]; } }, // Load translations for a specific locale async loadLocale(localeCode = null) { const targetLocale = localeCode || localStorage.getItem('locale') || 'en-US'; try { const response = await fetch(`/api/locales/${targetLocale}`); if (response.ok) { this.translations = await response.json(); this.currentLocale = targetLocale; localStorage.setItem('locale', targetLocale); } else if (targetLocale !== 'en-US') { // Fallback to en-US if requested locale not found await this.loadLocale('en-US'); } } catch (error) { console.error('Failed to load locale:', error); // If en-US also fails, translations will be empty and t() will return keys if (targetLocale !== 'en-US') { await this.loadLocale('en-US'); } } }, // Change locale and reload translations async changeLocale(localeCode) { await this.loadLocale(localeCode); }, // ==================== END INTERNATIONALIZATION ==================== // 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.buildNoteLookupMaps(); // Build O(1) lookup maps this.buildFolderTree(); await this.loadTags(); // Load tags after notes are loaded } catch (error) { ErrorHandler.handle('load notes', error); } }, // Build lookup maps for O(1) wikilink resolution buildNoteLookupMaps() { // Clear existing maps this._noteLookup.byPath.clear(); this._noteLookup.byPathLower.clear(); this._noteLookup.byName.clear(); this._noteLookup.byNameLower.clear(); this._noteLookup.byEndPath.clear(); this._mediaLookup.clear(); for (const note of this.notes) { const path = note.path; const pathLower = path.toLowerCase(); const name = note.name; const nameLower = name.toLowerCase(); // Handle media files separately - build media lookup map if (note.type !== 'note') { // Map filename WITH extension (case-insensitive) to full path // Use path to get filename with extension (note.name is stem without extension) const filenameWithExt = path.split('/').pop().toLowerCase(); // First match wins if there are duplicates if (!this._mediaLookup.has(filenameWithExt)) { this._mediaLookup.set(filenameWithExt, path); } continue; } // Notes only from here const nameWithoutMd = name.replace(/\.md$/i, ''); const nameWithoutMdLower = nameWithoutMd.toLowerCase(); // Store all variations for fast lookup this._noteLookup.byPath.set(path, true); this._noteLookup.byPath.set(path.replace(/\.md$/i, ''), true); this._noteLookup.byPathLower.set(pathLower, true); this._noteLookup.byPathLower.set(pathLower.replace(/\.md$/i, ''), true); this._noteLookup.byName.set(name, true); this._noteLookup.byName.set(nameWithoutMd, true); this._noteLookup.byNameLower.set(nameLower, true); this._noteLookup.byNameLower.set(nameWithoutMdLower, true); // End path matching (for /folder/note style links) this._noteLookup.byEndPath.set('/' + nameWithoutMdLower, true); this._noteLookup.byEndPath.set('/' + nameLower, true); } }, // Fast O(1) check if a wikilink target exists wikiLinkExists(linkTarget) { const targetLower = linkTarget.toLowerCase(); // Check all lookup maps return ( this._noteLookup.byPath.has(linkTarget) || this._noteLookup.byPath.has(linkTarget + '.md') || this._noteLookup.byPathLower.has(targetLower) || this._noteLookup.byPathLower.has(targetLower + '.md') || this._noteLookup.byName.has(linkTarget) || this._noteLookup.byNameLower.has(targetLower) || this._noteLookup.byEndPath.has('/' + targetLower) || this._noteLookup.byEndPath.has('/' + targetLower + '.md') ); }, // Resolve media wikilink to full path (O(1) lookup) // Returns the full path if found, null otherwise resolveMediaWikilink(mediaName) { const nameLower = mediaName.toLowerCase(); return this._mediaLookup.get(nameLower) || null; }, // Resolve a Markdown image path to a vault-relative path (forward slashes). // Relative paths use the note file's directory as base (same rules as CommonMark / browsers). // A leading "/" (not "//") means vault-root-relative. resolveMarkdownMediaPathForNote(noteVaultPath, rawSrc) { const pathPart = rawSrc.split('#')[0].split('?')[0]; if (!pathPart) return ''; if (pathPart.startsWith('/') && !pathPart.startsWith('//')) { return pathPart.replace(/^\/+/, ''); } if (!noteVaultPath) { return pathPart; } const dir = noteVaultPath.includes('/') ? noteVaultPath.slice(0, noteVaultPath.lastIndexOf('/')) : ''; const base = `https://vn.invalid/${dir ? `${dir}/` : ''}`; try { const u = new URL(pathPart, base); let p = u.pathname; if (p.startsWith('/')) p = p.slice(1); return p.split('/').filter(seg => seg !== '').map(seg => { try { return decodeURIComponent(seg); } catch (e) { return seg; } }).join('/'); } catch (e) { return pathPart; } }, encodeVaultRelativePathForMediaApi(vaultRelativePath) { if (!vaultRelativePath) return ''; return vaultRelativePath.split('/').map(segment => { try { return encodeURIComponent(decodeURIComponent(segment)); } catch (e) { return encodeURIComponent(segment); } }).join('/'); }, // 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); // Optional: no toast } }, // 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); // Optional: no toast } }, // Create a new note from a template async createNoteFromTemplate() { if (!this.selectedTemplate || !this.newTemplateNoteName.trim()) { return; } try { // Validate the note name const validation = FilenameValidator.validateFilename(this.newTemplateNoteName); if (!validation.valid) { this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' }); return; } // Determine the note path based on dropdown context let notePath = validation.sanitized; if (!notePath.endsWith('.md')) { notePath += '.md'; } const targetFolder = this.inferredNewItemTargetFolder(); // 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) { this.toast(this.t('notes.already_exists', { name: validation.sanitized }), { type: 'warning' }); 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(); this.toast(error.detail || this.t('templates.create_failed'), { type: 'error' }); 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); this.focusEditorForNewNote(); } catch (error) { ErrorHandler.handle('create note from template', error); } }, // Clear all tag filters clearTagFilters() { this.selectedTags = []; // Apply unified filtering this.applyFilters(); }, // ======================================================================== // Outline (TOC) Methods // ======================================================================== // Extract headings from markdown content for the outline extractOutline(content) { if (!content) { this.outline = []; this.backlinks = []; return; } const headings = []; const lines = content.split('\n'); const slugCounts = {}; // Track duplicate slugs // Skip frontmatter and code blocks let inFrontmatter = false; let inCodeBlock = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Handle frontmatter if (i === 0 && line.trim() === '---') { inFrontmatter = true; continue; } if (inFrontmatter) { if (line.trim() === '---') { inFrontmatter = false; } continue; } // Handle fenced code blocks (``` or ~~~) if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) { inCodeBlock = !inCodeBlock; continue; } if (inCodeBlock) { continue; } // Match heading lines (# to ######) const match = line.match(/^(#{1,6})\s+(.+)$/); if (match) { const level = match[1].length; const text = match[2].trim(); // Generate slug (GitHub-style) let slug = text .toLowerCase() .replace(/[^\w\s-]/g, '') // Remove special chars .replace(/\s+/g, '-') // Spaces to dashes .replace(/-+/g, '-'); // Multiple dashes to single // Handle duplicate slugs if (slugCounts[slug] !== undefined) { slugCounts[slug]++; slug = `${slug}-${slugCounts[slug]}`; } else { slugCounts[slug] = 0; } headings.push({ level, text, slug, line: i + 1 // 1-indexed line number }); } } this.outline = headings; }, // Scroll to a heading in the editor or preview scrollToHeading(heading) { if (this.viewMode === 'preview' || this.viewMode === 'split') { // In preview/split mode, scroll the preview pane const preview = document.querySelector('.markdown-preview'); if (preview) { // Find the heading element by text content (more reliable than ID) const headingElements = preview.querySelectorAll('h1, h2, h3, h4, h5, h6'); for (const el of headingElements) { if (el.textContent.trim() === heading.text) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Add a brief highlight effect el.style.transition = 'background-color 0.3s'; el.style.backgroundColor = 'var(--accent-light)'; setTimeout(() => { el.style.backgroundColor = ''; }, 1000); return; } } } } if (this.viewMode === 'edit' || this.viewMode === 'split') { // In edit/split mode, scroll the editor to the line const textarea = document.querySelector('.editor-textarea'); if (textarea && heading.line) { const lines = textarea.value.split('\n'); let charPos = 0; // Calculate character position of the heading line for (let i = 0; i < heading.line - 1 && i < lines.length; i++) { charPos += lines[i].length + 1; // +1 for newline } // Set cursor position and scroll textarea.focus(); textarea.setSelectionRange(charPos, charPos); // Calculate scroll position (approximate) const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24; const scrollTop = (heading.line - 1) * lineHeight - textarea.clientHeight / 3; textarea.scrollTop = Math.max(0, scrollTop); } } }, // Navigate to a backlink (note that links to current note) navigateToBacklink(backlinkPath) { this.loadNote(backlinkPath); }, // 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.isSearching = false; 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.isSearching = false; 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) { this.isSearching = true; 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); this.searchResults = []; } finally { this.isSearching = false; } } }, // 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 : []; }, // ==================== FAVORITES ==================== // Save favorites to localStorage saveFavorites() { try { localStorage.setItem('noteFavorites', JSON.stringify(this.favorites)); } catch (e) { console.warn('Could not save favorites to localStorage'); } }, // Check if a note is favorited (O(1) lookup) isFavorite(notePath) { return this.favoritesSet.has(notePath); }, // Toggle favorite status for a note toggleFavorite(notePath = null) { const path = notePath || this.currentNote; if (!path) return; if (this.favoritesSet.has(path)) { // Remove from favorites this.favorites = this.favorites.filter(f => f !== path); } else { // Add to favorites this.favorites = [...this.favorites, path]; } // Recreate Set from array for consistency this.favoritesSet = new Set(this.favorites); this.saveFavorites(); }, // Get favorite notes with full details (for display) get favoriteNotes() { return this.favorites .map(path => { // Find note by exact path or case-insensitive match let note = this.notes.find(n => n.path === path); if (!note) { note = this.notes.find(n => n.path.toLowerCase() === path.toLowerCase()); } if (!note) return null; return { path: note.path, // Use actual path from notes (fixes case issues) name: note.path.split('/').pop().replace('.md', ''), folder: note.folder || '' }; }) .filter(Boolean); // Remove nulls (deleted notes) }, saveFavoritesExpanded() { try { localStorage.setItem('favoritesExpanded', this.favoritesExpanded.toString()); } catch (e) { console.error('Error saving favorites expanded state:', e); } }, // Get current note's last modified time as relative string get lastEditedText() { if (!this.currentNote) return ''; const note = this.notes.find(n => n.path === this.currentNote); if (!note || !note.modified) return ''; const modified = new Date(note.modified); const now = new Date(); const diffMs = now - modified; const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffSecs < 60) return this.t('editor.just_now'); if (diffMins < 60) return this.t('editor.minutes_ago', { count: diffMins }); if (diffHours < 24) return this.t('editor.hours_ago', { count: diffHours }); if (diffDays < 7) return this.t('editor.days_ago', { count: diffDays }); // For older dates, show the date in selected locale return modified.toLocaleDateString(this.currentLocale, { month: 'short', day: 'numeric' }); }, // 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(this.getSortComparator()); } 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(this.getSortComparator()); } // 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; }, // ===================================================================== // DATA-ATTRIBUTE BASED HANDLERS // These read path/name/type from data-* attributes, avoiding JS escaping issues // ===================================================================== // Escape strings for HTML attributes (simpler than JS escaping) escapeHtmlAttr(str) { if (!str) return ''; return str .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); }, // Folder handlers - read from dataset handleFolderClick(el) { this.toggleFolder(el.dataset.path); }, handleFolderDragOver(el, event) { event.preventDefault(); this.dragOverFolder = el.dataset.path; el.classList.add('drag-over'); }, handleFolderDragLeave(el) { this.dragOverFolder = null; el.classList.remove('drag-over'); }, handleFolderDrop(el, event) { event.stopPropagation(); el.classList.remove('drag-over'); this.onFolderDrop(el.dataset.path); }, handleNewItemClick(el, event) { event.stopPropagation(); this.dropdownTargetFolder = el.dataset.path; this.toggleNewDropdown(event); }, handleRenameFolderClick(el, event) { event.stopPropagation(); this.renameFolder(el.dataset.path, el.dataset.name); }, handleDeleteFolderClick(el, event) { event.stopPropagation(); this.deleteFolder(el.dataset.path, el.dataset.name); }, // Item (note/media) handlers - read from dataset handleItemClick(el) { this.openItem(el.dataset.path, el.dataset.type); }, handleItemHover(el, isEnter) { const path = el.dataset.path; if (path !== this.currentNote && path !== this.currentMedia) { el.style.backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent'; } }, handleDeleteItemClick(el, event) { event.stopPropagation(); if (el.dataset.type !== 'note') { this.deleteMedia(el.dataset.path); } else { this.deleteNote(el.dataset.path, el.dataset.name); } }, // ===================================================================== // FOLDER TREE RENDERING // ===================================================================== // Render folder recursively (helper for deep nesting) // Uses data-* attributes to store path/name, avoiding JS string escaping issues renderFolderRecursive(folder, level = 0, isTopLevel = false) { if (!folder) return ''; let html = ''; const isExpanded = this.expandedFolders.has(folder.path); const esc = (s) => this.escapeHtmlAttr(s); // Shorthand for HTML escaping // Render this folder's header // Note: Using native event handlers with data-* attributes instead of Alpine directives // because x-html doesn't process Alpine directives in dynamically generated content html += `
${esc(folder.name)} ${folder.notes.length === 0 && (!folder.children || Object.keys(folder.children).length === 0) ? `(${this.t('folders.empty')})` : ''}
`; // If expanded, render folder contents (child folders + notes) if (isExpanded) { html += `
`; // First, render child folders (if any) if (folder.children && Object.keys(folder.children).length > 0) { const children = Object.entries(folder.children) .filter(([k, v]) => !this.hideUnderscoreFolders || !v.name.startsWith('_')) .sort((a, b) => this.getFolderSortComparator()(a[1], b[1])); children.forEach(([childKey, childFolder]) => { html += this.renderFolderRecursive(childFolder, 0, false); }); } // Then, render notes and images in this folder (after subfolders) if (folder.notes && folder.notes.length > 0) { folder.notes.forEach(note => { html += this.renderNoteItem(note); }); } html += `
`; // Close folder-contents } html += `
`; // Close folder wrapper return html; }, // Render a single note/media item (used by both folders and root level) renderNoteItem(note) { const esc = (s) => this.escapeHtmlAttr(s); const isMediaFile = note.type !== 'note'; const isCurrentNote = this.currentNote === note.path; const isCurrentMedia = this.currentMedia === note.path; const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote; // Share icon for shared notes const isShared = !isMediaFile && this.isNoteShared(note.path); const shareIcon = isShared ? `` : ''; const icon = this.getMediaIcon(note.type); const deleteTitle = !isMediaFile ? this.t('toolbar.delete_note') : note.type === 'drawing' ? this.t('toolbar.delete_drawing') : this.t('toolbar.delete_image'); return `
${shareIcon}${icon}${icon ? ' ' : ''}${esc(note.name)}
`; }, // Render root-level items (notes and media not in any folder) renderRootItems() { const root = this.folderTree['__root__']; if (!root || !root.notes || root.notes.length === 0) { return ''; } return root.notes.map(note => this.renderNoteItem(note)).join(''); }, // Toggle folder expansion toggleFolder(folderPath) { if (this.expandedFolders.has(folderPath)) { this.expandedFolders.delete(folderPath); } else { this.expandedFolders.add(folderPath); } // Force Alpine reactivity by creating new Set reference this.expandedFolders = new Set(this.expandedFolders); }, // Check if folder is expanded isFolderExpanded(folderPath) { return this.expandedFolders.has(folderPath); }, // Expand all folders expandAllFolders() { this.allFolders.forEach(folder => { this.expandedFolders.add(folder); }); // Force Alpine reactivity this.expandedFolders = new Set(this.expandedFolders); }, // Collapse all folders collapseAllFolders() { this.expandedFolders.clear(); // Force Alpine reactivity this.expandedFolders = new Set(this.expandedFolders); }, // Expand folder tree to show a specific note expandFolderForNote(notePath) { const parts = notePath.split('/'); // If note is in root, no folders to expand if (parts.length <= 1) return; // Remove the note name (last part) parts.pop(); // Build and expand all parent folders let currentPath = ''; parts.forEach((part, index) => { currentPath = index === 0 ? part : `${currentPath}/${part}`; this.expandedFolders.add(currentPath); }); // Force Alpine reactivity this.expandedFolders = new Set(this.expandedFolders); }, // Scroll note into view in the sidebar navigation scrollNoteIntoView(notePath) { // Find the note element in the sidebar // Use a slight delay to ensure DOM is fully rendered with Alpine bindings applied setTimeout(() => { const sidebar = document.querySelector('.flex-1.overflow-y-auto.custom-scrollbar'); if (!sidebar) return; const noteElements = sidebar.querySelectorAll('.note-item'); let targetElement = null; const noteName = notePath.split('/').pop().replace('.md', ''); // Find the element that corresponds to this note noteElements.forEach(el => { // Check if this is a note element (not folder) by checking if it has the note name if (el.textContent.trim().startsWith(noteName) || el.textContent.includes(noteName)) { // Check computed style to see if it's highlighted const computedStyle = window.getComputedStyle(el); const bgColor = computedStyle.backgroundColor; // Check if background has the accent color (not transparent or default) if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' && !bgColor.includes('255, 255, 255')) { targetElement = el; } } }); // If found, scroll it into view if (targetElement) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } }, 200); // Increased delay to ensure Alpine has finished rendering }, // Unified drag and drop handlers for notes, folders, and media onItemDragStart(itemPath, itemType, event) { // Set unified drag state this.draggedItem = { path: itemPath, type: itemType }; // Make drag image semi-transparent if (event.target) { event.target.style.opacity = '0.5'; } event.dataTransfer.effectAllowed = 'all'; }, onItemDragEnd() { this.draggedItem = null; this.dropTarget = null; this.dragOverFolder = null; // Reset opacity of all draggable items document.querySelectorAll('.note-item, .folder-header').forEach(el => el.style.opacity = '1'); // Reset drag-over class document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); }, // Handle dragover on editor to show cursor position onEditorDragOver(event) { if (!this.draggedItem) return; event.preventDefault(); this.dropTarget = 'editor'; // Focus the textarea const textarea = event.target; if (textarea.tagName !== 'TEXTAREA') return; textarea.focus(); // Calculate cursor position from mouse coordinates const pos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); if (pos >= 0) { textarea.setSelectionRange(pos, pos); } }, // Calculate textarea cursor position from mouse coordinates getTextareaCursorFromPoint(textarea, x, y) { const rect = textarea.getBoundingClientRect(); const style = window.getComputedStyle(textarea); const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2; const paddingTop = parseFloat(style.paddingTop) || 0; const paddingLeft = parseFloat(style.paddingLeft) || 0; // Calculate which line we're on const relativeY = y - rect.top - paddingTop + textarea.scrollTop; const lineIndex = Math.max(0, Math.floor(relativeY / lineHeight)); // Split content into lines const lines = textarea.value.split('\n'); // Find the character position at the start of this line let charPos = 0; for (let i = 0; i < Math.min(lineIndex, lines.length); i++) { charPos += lines[i].length + 1; // +1 for newline } // If we're beyond the last line, position at end if (lineIndex >= lines.length) { return textarea.value.length; } // Approximate character position within the line based on X coordinate const relativeX = x - rect.left - paddingLeft; const charWidth = parseFloat(style.fontSize) * 0.6; // Approximate for monospace const charInLine = Math.max(0, Math.floor(relativeX / charWidth)); const lineLength = lines[lineIndex]?.length || 0; return charPos + Math.min(charInLine, lineLength); }, // Handle dragenter on editor onEditorDragEnter(event) { if (!this.draggedItem) return; event.preventDefault(); this.dropTarget = 'editor'; }, // Handle dragleave on editor onEditorDragLeave(event) { // Only clear dropTarget if we're actually leaving the editor // (not just moving between child elements) if (event.target.tagName === 'TEXTAREA') { this.dropTarget = null; } }, // Handle drop into editor to create internal link or upload media async onEditorDrop(event) { event.preventDefault(); this.dropTarget = null; // Check if files are being dropped (media from file system) if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { await this.handleMediaDrop(event); return; } // Otherwise, handle note/media link drop from sidebar if (!this.draggedItem) return; const notePath = this.draggedItem.path; const isMediaFile = this.draggedItem.type !== 'note'; let link; if (isMediaFile) { // For media files (images, audio, video, PDF), use wiki-style embed link const filename = notePath.split('/').pop(); link = `![[${filename}]]`; } else { // For notes, insert note link const noteName = notePath.split('/').pop().replace('.md', ''); const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); link = `[${noteName}](${encodedPath})`; } // Insert at drop position const textarea = event.target; // Recalculate position from drop coordinates for accuracy let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; const textBefore = this.noteContent.substring(0, cursorPos); const textAfter = this.noteContent.substring(cursorPos); this.noteContent = textBefore + link + textAfter; // Move cursor after the link this.$nextTick(() => { textarea.selectionStart = textarea.selectionEnd = cursorPos + link.length; textarea.focus(); }); // Trigger autosave this.autoSave(); this.draggedItem = null; }, /** * Backend `get_attachment_dir` uses the parent folder of `note_path`. * With no note open, infer a synthetic path from `currentMedia` so uploads go to the same * `_attachments` folder as the file being viewed (or vault root when appropriate). */ resolveUploadNotePath() { if (this.currentNote) return this.currentNote; if (!this.currentMedia) return ''; const parts = this.currentMedia.split('/').filter(Boolean); const ai = parts.indexOf('_attachments'); if (ai === -1 || ai === 0) return ''; return `${parts.slice(0, ai).join('/')}/_.md`; }, // Handle media files dropped into editor async handleMediaDrop(event) { const files = Array.from(event.dataTransfer.files); // Filter for allowed media types const allowedTypes = [ // Images 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', // Audio 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a', // Video 'video/mp4', 'video/webm', 'video/quicktime', // Documents 'application/pdf' ]; const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase())); if (mediaFiles.length === 0) { this.toast(this.t('media.no_valid_files'), { type: 'warning' }); return; } const textarea = event.target; const notePath = this.resolveUploadNotePath(); // Calculate cursor position from drop coordinates (only meaningful when a note is open) let cursorPos = 0; if (this.currentNote && textarea && textarea.tagName === 'TEXTAREA') { cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; } let uploaded = false; for (const file of mediaFiles) { try { const mediaPath = await this.uploadMedia(file, notePath); if (mediaPath) { uploaded = true; if (this.currentNote) { await this.insertMediaMarkdown(mediaPath, file.name, cursorPos); } } } catch (error) { ErrorHandler.handle(`upload file ${file.name}`, error); } } if (uploaded && !this.currentNote) { await this.loadNotes(); } }, // Upload a media file (image, audio, video, PDF) // Use options.nextToNotes + options.contentFolder to save a new drawing PNG next to .md files (not in _attachments). async uploadMedia(file, notePath, options = {}) { const { nextToNotes, contentFolder } = options; const formData = new FormData(); formData.append('file', file); if (nextToNotes) { formData.append('next_to_notes', '1'); formData.append('content_folder', contentFolder != null ? contentFolder : ''); formData.append('note_path', ''); } else { formData.append('note_path', notePath || ''); } try { const response = await fetch('/api/upload-media', { method: 'POST', body: formData }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Upload failed'); } const data = await response.json(); return data.path; } catch (error) { throw error; } }, // Insert media markdown at cursor position using wiki-style syntax // This ensures media links don't break when notes are moved async insertMediaMarkdown(mediaPath, altText, cursorPos) { // Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png") const filename = mediaPath.split('/').pop(); // Use wiki-style embed link: ![[filename.png]] or ![[filename.png|alt text]] // The alt text is optional - only add if different from filename const filenameWithoutExt = filename.replace(/\.[^/.]+$/, ''); const altWithoutExt = altText.replace(/\.[^/.]+$/, ''); // If alt text is meaningful (not just "pasted-image"), include it const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image')) ? `![[${filename}|${altWithoutExt}]]` : `![[${filename}]]`; // Reload notes FIRST to update image lookup maps before preview renders await this.loadNotes(); const textBefore = this.noteContent.substring(0, cursorPos); const textAfter = this.noteContent.substring(cursorPos); this.noteContent = textBefore + markdown + '\n' + textAfter; // Trigger autosave this.autoSave(); }, // Handle paste event for clipboard media (images) async handlePaste(event) { if (!this.currentNote) return; const items = event.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { event.preventDefault(); const blob = item.getAsFile(); if (blob) { try { const textarea = event.target; const cursorPos = textarea.selectionStart || 0; // Create a simple filename - backend will add timestamp to prevent collisions const ext = item.type.split('/')[1] || 'png'; const filename = `pasted-image.${ext}`; // Create a File from the blob const file = new File([blob], filename, { type: item.type }); const mediaPath = await this.uploadMedia(file, this.currentNote); if (mediaPath) { await this.insertMediaMarkdown(mediaPath, filename, cursorPos); } } catch (error) { ErrorHandler.handle('paste media', error); } } break; // Only handle first media item } } }, // Media type detection based on file extension (and drawing-*.png convention) getMediaType(filename) { if (!filename) return null; const base = filename.split('/').pop().toLowerCase(); if (base.startsWith('drawing-') && base.endsWith('.png')) { return 'drawing'; } const ext = filename.split('.').pop().toLowerCase(); const mediaTypes = { image: ['jpg', 'jpeg', 'png', 'gif', 'webp'], audio: ['mp3', 'wav', 'ogg', 'm4a'], video: ['mp4', 'webm', 'mov', 'avi'], document: ['pdf'], }; for (const [type, extensions] of Object.entries(mediaTypes)) { if (extensions.includes(ext)) return type; } return null; }, // Get icon for media type getMediaIcon(type) { const icons = { image: 'πŸ–ΌοΈ', drawing: '✏️', audio: '🎡', video: '🎬', document: 'πŸ“„', }; return icons[type] || ''; }, // Open a note or media file (unified handler for sidebar/homepage clicks) openItem(path, type = 'note', searchHighlight = '') { this.showGraph = false; // Check if it's a media file by type or extension const mediaType = type !== 'note' ? type : this.getMediaType(path); if (mediaType && mediaType !== 'note') { this.viewMedia(path, mediaType); } else { this.loadNote(path, true, searchHighlight); } }, // View a media file (image, audio, video, PDF) in the main pane viewMedia(mediaPath, mediaType = null, updateHistory = true) { if (this.currentMediaType === 'drawing') { this._drawingDisconnectResizeObserver(); this._drawingCancelAutosave(); } this.showGraph = false; // Ensure graph is closed this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; this.currentMedia = mediaPath; // Reuse currentMedia for all media this.currentMediaType = mediaType || this.getMediaType(mediaPath) || 'image'; this.shareInfo = null; // Reset share info this.viewMode = 'preview'; // Use preview mode to show media // Update browser tab title const fileName = mediaPath.split('/').pop(); document.title = `${fileName} - ${this.appName}`; // Expand folder tree to show the media file this.expandFolderForNote(mediaPath); // Update browser URL if (updateHistory) { // Encode each path segment to handle special characters const encodedPath = mediaPath.split('/').map(segment => encodeURIComponent(segment)).join('/'); window.history.pushState( { mediaPath: mediaPath }, '', `/${encodedPath}` ); } // Drawing: Alpine x-init on the canvas runs only on first mount; switching from one drawing // to another keeps currentMediaType === 'drawing', so we must reload the PNG here. if (this.currentMediaType === 'drawing') { this.$nextTick(() => { this.initDrawingViewer(); }); } }, // Backward compatibility alias viewImage(mediaPath, updateHistory = true) { this.viewMedia(mediaPath, 'image', updateHistory); }, // Delete a media file (image, audio, video, PDF) async deleteMedia(mediaPath) { const filename = mediaPath.split('/').pop(); const ok = await this.confirmModalAsk({ message: this.t('media.confirm_delete', { name: filename }), }); if (!ok) return; try { const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, { method: 'DELETE' }); if (response.ok) { await this.loadNotes(); // Refresh tree // Clear viewer if deleting currently viewed media if (this.currentMedia === mediaPath) { this.currentMedia = ''; } } else { throw new Error('Failed to delete media file'); } } catch (error) { ErrorHandler.handle('delete media', error); } }, /** * Create a blank drawing PNG and open it for editing. * Attachment folder matches "New note" / "New folder" from the same + menu (root vs folder row vs homepage folder). */ /** * Create a brand-new drawing PNG and open it in the editor. * @param {{docW?: number, docH?: number}} [opts] * Optional intrinsic dimensions in document pixels. Phase-2: a "New drawing" * dialog (presets, A4, custom) only needs to pass these values here β€” no other * plumbing is required because every downstream function reads doc size from * either the loaded PNG or the in-memory drawingDocW/drawingDocH fields. */ async createNewDrawing(opts = {}) { const targetFolder = this.inferredNewItemTargetFolder(); this.closeDropdown(); const w = this._drawingClampDim(opts.docW, CONFIG.DRAWING_DEFAULT_DOC_W); const h = this._drawingClampDim(opts.docH, CONFIG.DRAWING_DEFAULT_DOC_H); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.fillStyle = CONFIG.DRAWING_BACKGROUND; ctx.fillRect(0, 0, w, h); let blob; try { blob = await new Promise((resolve, reject) => { canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); }); } catch (e) { ErrorHandler.handle('create drawing', e); return; } const file = new File([blob], 'drawing.png', { type: 'image/png' }); try { const path = await this.uploadMedia(file, '', { nextToNotes: true, contentFolder: targetFolder, }); await this.loadNotes(); this.viewMedia(path, 'drawing'); } catch (error) { ErrorHandler.handle('create drawing', error); } }, _drawingEncodeMediaPath() { return this.currentMedia.split('/').map((s) => encodeURIComponent(s)).join('/'); }, /** * Clamp a dimension to the [MIN, MAX] document-px range. Falls back to `fallback` * when value is missing, non-finite or non-positive. Always returns an integer. */ _drawingClampDim(value, fallback) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return Math.round(fallback); const clamped = Math.min( CONFIG.DRAWING_MAX_DOC_DIM, Math.max(CONFIG.DRAWING_MIN_DOC_DIM, n) ); return Math.round(clamped); }, /** * Convert a pointer event to DOCUMENT-space coordinates (0..drawingDocW, 0..drawingDocH). * The mapping is independent of CSS size, DPR and zoom β€” strokes stored from this * function render identically on any device and round-trip cleanly through PNG export. */ _drawingCanvasCoords(event) { const canvas = this._drawingCanvasEl; if (!canvas) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return { x: 0, y: 0 }; return { x: ((event.clientX - rect.left) / rect.width) * this.drawingDocW, y: ((event.clientY - rect.top) / rect.height) * this.drawingDocH, }; }, _drawingDrawOp(ctx, op) { if (!op) return; if (op.type === 'stroke') { const pts = op.points; if (!pts || pts.length < 2) return; ctx.save(); ctx.strokeStyle = op.color; ctx.lineWidth = op.lineWidth; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) { ctx.lineTo(pts[i][0], pts[i][1]); } ctx.stroke(); ctx.restore(); return; } ctx.save(); ctx.strokeStyle = op.color; ctx.lineWidth = op.lineWidth; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (op.type === 'line') { ctx.beginPath(); ctx.moveTo(op.x1, op.y1); ctx.lineTo(op.x2, op.y2); ctx.stroke(); } else if (op.type === 'rect') { const nx = op.w < 0 ? op.x + op.w : op.x; const ny = op.h < 0 ? op.y + op.h : op.y; ctx.strokeRect(nx, ny, Math.abs(op.w), Math.abs(op.h)); } else if (op.type === 'ellipse') { const nx = op.w < 0 ? op.x + op.w : op.x; const ny = op.h < 0 ? op.y + op.h : op.y; const rw = Math.abs(op.w) / 2; const rh = Math.abs(op.h) / 2; const cx = nx + rw; const cy = ny + rh; ctx.beginPath(); ctx.ellipse(cx, cy, rw, rh, 0, 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); }, /** * Repaint the visible canvas. All drawing math runs in DOCUMENT space; a single * setTransform call maps (docW, docH) β†’ device pixels by combining the docβ†’display * scale with the device pixel ratio. _drawingDrawOp itself is space-agnostic. */ drawingRedraw() { const canvas = this._drawingCanvasEl; const ctx = this._drawingCtx; if (!canvas || !ctx) return; const docW = this.drawingDocW; const docH = this.drawingDocH; const cssW = this._drawingCssW; const cssH = this._drawingCssH; const dpr = this._drawingDpr || 1; if (!docW || !docH || !cssW || !cssH) return; const sx = (cssW / docW) * dpr; const sy = (cssH / docH) * dpr; ctx.setTransform(sx, 0, 0, sy, 0, 0); ctx.clearRect(0, 0, docW, docH); ctx.fillStyle = CONFIG.DRAWING_BACKGROUND; ctx.fillRect(0, 0, docW, docH); if (this._drawingBaseImage && this._drawingBaseImage.complete) { ctx.drawImage(this._drawingBaseImage, 0, 0, docW, docH); } for (const op of this.drawingOps) { this._drawingDrawOp(ctx, op); } if (this.drawingDraft) { this._drawingDrawOp(ctx, this.drawingDraft); } }, _drawingScheduleRedraw() { if (this._drawingRaf) return; this._drawingRaf = requestAnimationFrame(() => { this._drawingRaf = null; this.drawingRedraw(); }); }, _drawingDisconnectResizeObserver() { if (this._drawingResizeObserver) { try { this._drawingResizeObserver.disconnect(); } catch (_) { /* ignore */ } this._drawingResizeObserver = null; } }, _drawingCancelAutosave() { if (this._drawingAutosaveTimeout) { clearTimeout(this._drawingAutosaveTimeout); this._drawingAutosaveTimeout = null; } }, /** * Debounced PNG save (same delay as notes). Never runs while the primary button is held * (active stroke/shape); pending timers are cleared when a new stroke starts. */ _drawingScheduleAutosave() { if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; if (this._drawingAutosaveTimeout) { clearTimeout(this._drawingAutosaveTimeout); this._drawingAutosaveTimeout = null; } const path = this.currentMedia; const pollMs = 150; const attemptSave = () => { if (this.currentMedia !== path || this.currentMediaType !== 'drawing') { this._drawingAutosaveTimeout = null; return; } if (this.drawingIsPointerDown) { this._drawingAutosaveTimeout = setTimeout(attemptSave, pollMs); return; } this._drawingAutosaveTimeout = null; this.drawingSave(); }; this._drawingAutosaveTimeout = setTimeout(attemptSave, CONFIG.AUTOSAVE_DELAY); }, /** * Size the visible canvas as a letterbox of (drawingDocW Γ— drawingDocH) inside the * available wrap, preserving aspect ratio. The display canvas may be smaller than * the wrap on narrow viewports β€” that is intentional. The document space stays * fixed; only the on-screen viewport scales. */ _drawingLayoutCanvas() { const wrap = this.$refs.drawingCanvasWrap; const canvas = this.$refs.drawingCanvas; if (!wrap || !canvas || this.currentMediaType !== 'drawing') return; // Available area (with sane fallbacks for the brief moment before layout settles). let availW = wrap.clientWidth; let availH = wrap.clientHeight; if (availW < 32) { availW = Math.max(320, wrap.parentElement ? wrap.parentElement.clientWidth : 320); } if (availH < 32) { const col = wrap.closest('.flex-1.flex.flex-col') || wrap.closest('.flex-1'); const h = col ? col.clientHeight : 0; availH = h > 64 ? h : Math.max(240, Math.floor((availW || 400) * 0.5)); } // Letterbox the document into the available area. const docW = this.drawingDocW; const docH = this.drawingDocH; const docAspect = docW / docH; const wrapAspect = availW / availH; let cssW; let cssH; if (wrapAspect > docAspect) { cssH = availH; cssW = cssH * docAspect; } else { cssW = availW; cssH = cssW / docAspect; } const dpr = window.devicePixelRatio || 1; canvas.style.width = `${cssW}px`; canvas.style.height = `${cssH}px`; // Backing store in device pixels β€” capped above by availW/availH * dpr, so this // never allocates more than the viewport could ever show. Cheap on every device. canvas.width = Math.max(1, Math.round(cssW * dpr)); canvas.height = Math.max(1, Math.round(cssH * dpr)); this._drawingCanvasEl = canvas; this._drawingCssW = cssW; this._drawingCssH = cssH; this._drawingDpr = dpr; if (!this._drawingCtx) { this._drawingCtx = canvas.getContext('2d'); } this.drawingRedraw(); }, async initDrawingViewer() { if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; this._drawingDisconnectResizeObserver(); await this.$nextTick(); if (this._drawingObjectURL) { URL.revokeObjectURL(this._drawingObjectURL); this._drawingObjectURL = null; } const canvas = this.$refs.drawingCanvas; const wrap = this.$refs.drawingCanvasWrap; if (!canvas || !wrap) return; this._drawingCtx = canvas.getContext('2d'); this.drawingOps = []; this.drawingRedoStack = []; this.drawingDraft = null; this.drawingIsPointerDown = false; this._drawingBaseImage = null; this.drawingHasRasterFromFile = false; // Reset to defaults; replaced by the loaded PNG's natural size below. // For drawings created via createNewDrawing() the file already matches the defaults // (or the size requested by a future "new drawing" dialog). this.drawingDocW = CONFIG.DRAWING_DEFAULT_DOC_W; this.drawingDocH = CONFIG.DRAWING_DEFAULT_DOC_H; this._drawingLoadToken = Symbol(); const token = this._drawingLoadToken; this._drawingResizeObserver = new ResizeObserver(() => { if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; this._drawingLayoutCanvas(); }); this._drawingResizeObserver.observe(wrap); requestAnimationFrame(() => { if (token !== this._drawingLoadToken) return; this._drawingLayoutCanvas(); requestAnimationFrame(() => { if (token !== this._drawingLoadToken) return; this._drawingLayoutCanvas(); }); }); try { const enc = this._drawingEncodeMediaPath(); const res = await fetch(`/api/media/${enc}`, { credentials: 'same-origin' }); if (!res.ok) throw new Error('Failed to load drawing'); const blob = await res.blob(); if (token !== this._drawingLoadToken) return; const url = URL.createObjectURL(blob); this._drawingObjectURL = url; const img = new Image(); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; }); if (token !== this._drawingLoadToken) return; this._drawingBaseImage = img; this.drawingHasRasterFromFile = true; // Adopt the PNG's intrinsic size as the document size for this drawing. // The PNG itself is the source of truth β€” no sidecar metadata required. this.drawingDocW = this._drawingClampDim(img.naturalWidth, CONFIG.DRAWING_DEFAULT_DOC_W); this.drawingDocH = this._drawingClampDim(img.naturalHeight, CONFIG.DRAWING_DEFAULT_DOC_H); this._drawingLayoutCanvas(); } catch (e) { if (token !== this._drawingLoadToken) return; ErrorHandler.handle('load drawing', e); this.drawingHasRasterFromFile = false; this._drawingLayoutCanvas(); } }, _drawingRgbToHex(r, g, b) { const h = (n) => { const s = n.toString(16); return s.length === 1 ? `0${s}` : s; }; return `#${h(r)}${h(g)}${h(b)}`; }, /** * Sample visible canvas color under the pointer; sets drawingColor. * Pointer coords come in document space; we map to device pixels via the canvas's * actual backing store (which already includes both the docβ†’display scale and DPR). */ drawingSampleColor(e) { const canvas = this._drawingCanvasEl; const ctx = this._drawingCtx; if (!canvas || !ctx) return; const docW = this.drawingDocW; const docH = this.drawingDocH; if (!docW || !docH) return; const { x, y } = this._drawingCanvasCoords(e); this.drawingRedraw(); let ix = Math.floor((x / docW) * canvas.width); let iy = Math.floor((y / docH) * canvas.height); ix = Math.max(0, Math.min(ix, canvas.width - 1)); iy = Math.max(0, Math.min(iy, canvas.height - 1)); const pix = ctx.getImageData(ix, iy, 1, 1).data; this.drawingColor = this._drawingRgbToHex(pix[0], pix[1], pix[2]); }, /** True when there is a loaded bitmap and/or session strokes to clear away. */ drawingClearEnabled() { if (this.currentMediaType !== 'drawing') return false; return !!(this.drawingHasRasterFromFile || (this.drawingOps && this.drawingOps.length > 0)); }, /** * Replace the in-memory drawing with a blank canvas and schedule save so the file on disk * becomes a fresh white PNG (same dimensions as the viewer). Drops the loaded raster. */ async drawingClear() { if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; if (!this._drawingBaseImage && this.drawingOps.length === 0) return; const ok = await this.confirmModalAsk({ title: this.t('drawing.clear_title'), message: this.t('drawing.clear_confirm'), danger: true, confirmLabel: this.t('drawing.clear'), }); if (!ok) return; this._drawingCancelAutosave(); if (this._drawingObjectURL) { try { URL.revokeObjectURL(this._drawingObjectURL); } catch (_) { /* ignore */ } this._drawingObjectURL = null; } this._drawingBaseImage = null; this.drawingHasRasterFromFile = false; this.drawingDraft = null; this.drawingIsPointerDown = false; this.drawingOps = []; this.drawingRedoStack = []; this.drawingRedraw(); this._drawingScheduleAutosave(); }, drawingPointerDown(e) { if (this.currentMediaType !== 'drawing' || e.button !== 0) return; const canvas = this._drawingCanvasEl; if (!canvas) return; const tool = this.drawingTool; if (tool === 'eyedropper') { e.preventDefault(); this.drawingSampleColor(e); return; } this._drawingCancelAutosave(); canvas.setPointerCapture(e.pointerId); this._drawingPointerId = e.pointerId; const { x, y } = this._drawingCanvasCoords(e); this.drawingIsPointerDown = true; this.drawingRedoStack = []; const lw = this.drawingLineWidth; const color = tool === 'eraser' ? CONFIG.DRAWING_BACKGROUND : this.drawingColor; if (tool === 'freehand' || tool === 'eraser') { this.drawingDraft = { type: 'stroke', color, lineWidth: lw, points: [[x, y]] }; } else if (tool === 'line') { this.drawingDraft = { type: 'line', color, lineWidth: lw, x1: x, y1: y, x2: x, y2: y }; } else if (tool === 'rect') { this.drawingDraft = { type: 'rect', color, lineWidth: lw, x, y, w: 0, h: 0 }; } else if (tool === 'ellipse') { this.drawingDraft = { type: 'ellipse', color, lineWidth: lw, x, y, w: 0, h: 0 }; } this.drawingRedraw(); }, drawingPointerMove(e) { if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return; const { x, y } = this._drawingCanvasCoords(e); const d = this.drawingDraft; if (!d) return; if (d.type === 'stroke') { const pts = d.points; const last = pts[pts.length - 1]; const dx = x - last[0]; const dy = y - last[1]; if (dx * dx + dy * dy < 1) return; pts.push([x, y]); this._drawingScheduleRedraw(); return; } if (d.type === 'line') { d.x2 = x; d.y2 = y; } else if (d.type === 'rect' || d.type === 'ellipse') { d.w = x - d.x; d.h = y - d.y; } this._drawingScheduleRedraw(); }, drawingPointerUp(e) { if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return; const canvas = this._drawingCanvasEl; if (canvas && this._drawingPointerId === e.pointerId) { try { canvas.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ } } this._drawingPointerId = null; this.drawingIsPointerDown = false; const d = this.drawingDraft; this.drawingDraft = null; if (!d) { this.drawingRedraw(); return; } if (d.type === 'stroke') { if (!d.points || d.points.length < 2) { this.drawingRedraw(); return; } this.drawingOps.push({ type: 'stroke', color: d.color, lineWidth: d.lineWidth, points: d.points.slice(), }); } else if (d.type === 'line') { const dx = d.x2 - d.x1; const dy = d.y2 - d.y1; if (dx * dx + dy * dy < 4) { this.drawingRedraw(); return; } this.drawingOps.push({ type: 'line', color: d.color, lineWidth: d.lineWidth, x1: d.x1, y1: d.y1, x2: d.x2, y2: d.y2, }); } else if (d.type === 'rect' || d.type === 'ellipse') { if (Math.abs(d.w) < 2 && Math.abs(d.h) < 2) { this.drawingRedraw(); return; } this.drawingOps.push({ type: d.type, color: d.color, lineWidth: d.lineWidth, x: d.x, y: d.y, w: d.w, h: d.h, }); } this.drawingRedraw(); this._drawingScheduleAutosave(); }, drawingUndo() { if (this.drawingOps.length === 0) return; this.drawingRedoStack.push(this.drawingOps.pop()); this.drawingRedraw(); this._drawingScheduleAutosave(); }, drawingRedo() { if (this.drawingRedoStack.length === 0) return; this.drawingOps.push(this.drawingRedoStack.pop()); this.drawingRedraw(); this._drawingScheduleAutosave(); }, /** * Persist the flattened canvas to disk (Ctrl+S, autosave). Same feedback as saveNote: * header "Saved" only β€” never clears stroke undo/redo or reloads the image; stacks reset when * opening another drawing via initDrawingViewer(). */ async drawingSave() { if (!this.currentMedia || this.currentMediaType !== 'drawing') return; if (this._drawingSaveInFlight) { this._drawingSaveQueued = true; return; } this._drawingSaveInFlight = true; try { this._drawingCancelAutosave(); this.drawingDraft = null; this.drawingIsPointerDown = false; // Render onto an off-screen canvas at the exact document size. This makes the // exported PNG byte-deterministic across devices: same ops, same dimensions β†’ // identical bytes regardless of DPR, window size, or zoom level. The off-screen // canvas is allocated per save (microseconds for 1600Γ—1000) so dimension changes // from a future "resize drawing" feature can never leak between saves. const docW = this._drawingClampDim(this.drawingDocW, CONFIG.DRAWING_DEFAULT_DOC_W); const docH = this._drawingClampDim(this.drawingDocH, CONFIG.DRAWING_DEFAULT_DOC_H); const off = document.createElement('canvas'); off.width = docW; off.height = docH; const offCtx = off.getContext('2d'); if (!offCtx) return; offCtx.fillStyle = CONFIG.DRAWING_BACKGROUND; offCtx.fillRect(0, 0, docW, docH); if (this._drawingBaseImage && this._drawingBaseImage.complete) { offCtx.drawImage(this._drawingBaseImage, 0, 0, docW, docH); } for (const op of this.drawingOps) { this._drawingDrawOp(offCtx, op); } // Keep the visible canvas in sync (cosmetic; UI doesn't have to wait for the upload). this.drawingRedraw(); const blob = await new Promise((resolve, reject) => { off.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); }); this.isSaving = true; try { const enc = this._drawingEncodeMediaPath(); const res = await fetch(`/api/media/${enc}`, { method: 'PUT', body: blob, headers: { 'Content-Type': 'image/png' }, credentials: 'same-origin', }); if (!res.ok) { const err = await res.json().catch(() => ({})); let detail = err.detail; if (Array.isArray(detail)) { detail = detail.map((d) => d.msg || d).join(', '); } throw new Error(detail || res.statusText); } await this.loadNotes(); this.lastSaved = true; setTimeout(() => { this.lastSaved = false; }, CONFIG.SAVE_INDICATOR_DURATION); } catch (error) { ErrorHandler.handle('save drawing', error); } finally { this.isSaving = false; } } finally { this._drawingSaveInFlight = false; if (this._drawingSaveQueued) { this._drawingSaveQueued = false; queueMicrotask(() => { if (this.currentMedia && this.currentMediaType === 'drawing') { this.drawingSave(); } }); } } }, // Handle clicks on internal links in preview handleInternalLink(event) { // Check if clicked element is a link const link = event.target.closest('a'); if (!link) return; const href = link.getAttribute('href'); if (!href) return; // Check if it's an external link or API path (media files, etc.) // Safe external protocols: http, https, mailto, tel, ssh, ftp, sftp, and app deep links const externalProtocols = ['http://', 'https://', '//', 'mailto:', 'tel:', 'ssh:', 'ftp:', 'sftp:', 'slack:', 'discord:', 'teams:', 'vscode:', 'zoom:', 'whatsapp:', 'telegram:', 'signal:', 'spotify:', 'steam:', 'magnet:', '/api/']; if (externalProtocols.some(p => href.startsWith(p))) { return; // Let external links and API paths work normally } // Prevent default navigation for internal links event.preventDefault(); // Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section") const decodedHref = decodeURIComponent(href); const hashIndex = decodedHref.indexOf('#'); const notePath = hashIndex !== -1 ? decodedHref.substring(0, hashIndex) : decodedHref; const anchor = hashIndex !== -1 ? decodedHref.substring(hashIndex + 1) : null; // If it's just an anchor link (#heading), scroll within current note if (!notePath && anchor) { this.scrollToAnchor(anchor); return; } // Skip if no path if (!notePath) return; // Find the note by path (try exact match first, then with .md extension) let targetNote = this.notes.find(n => n.path === notePath || n.path === notePath + '.md' ); if (!targetNote) { // Try to find by name (in case link uses just the note name without path) targetNote = this.notes.find(n => n.name === notePath || n.name === notePath + '.md' || n.name.toLowerCase() === notePath.toLowerCase() || n.name.toLowerCase() === (notePath + '.md').toLowerCase() ); } if (!targetNote) { // Last resort: case-insensitive path matching targetNote = this.notes.find(n => n.path.toLowerCase() === notePath.toLowerCase() || n.path.toLowerCase() === (notePath + '.md').toLowerCase() ); } if (targetNote) { // Load the note, then scroll to anchor if present this.loadNote(targetNote.path).then(() => { if (anchor) { // Small delay to ensure content is rendered setTimeout(() => this.scrollToAnchor(anchor), 100); } }); } else { this.confirmModalAsk({ message: this.t('notes.create_from_link', { path: notePath }), danger: false, title: this.t('sidebar.new_note'), confirmLabel: this.t('common.create'), }).then((ok) => { if (ok) this.createNote(null, notePath); }); } }, // Scroll to an anchor (heading) by slug - reuses outline data scrollToAnchor(anchor) { // Normalize the anchor (GitHub-style slug) const targetSlug = anchor .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-'); // Find matching heading in outline const heading = this.outline.find(h => h.slug === targetSlug); if (heading) { this.scrollToHeading(heading); } else { // Fallback: try to find heading by exact text match const headingByText = this.outline.find(h => h.text.toLowerCase().replace(/\s+/g, '-') === anchor.toLowerCase() ); if (headingByText) { this.scrollToHeading(headingByText); } } }, cancelDrag() { // Cancel any active drag operation (triggered by ESC key) this.draggedItem = null; this.dropTarget = null; this.dragOverFolder = null; // Reset styles - only query elements with drag-over class (more efficient) document.querySelectorAll('.folder-item').forEach(el => el.style.opacity = '1'); document.querySelectorAll('.note-item').forEach(el => el.style.opacity = '1'); document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); }, async onFolderDrop(targetFolderPath) { // Ignore if we're dropping into the editor if (this.dropTarget === 'editor') { return; } // Capture dragged item info immediately (ondragend may clear it) if (!this.draggedItem) return; const { path: draggedPath, type: draggedType } = this.draggedItem; // Determine item category for endpoint selection const isFolder = draggedType === 'folder'; const isNote = draggedType === 'note'; const isMedia = !isFolder && !isNote; // image, audio, video, document // Handle folder drop if (isFolder) { // Prevent dropping folder into itself or its subfolders if (targetFolderPath === draggedPath || targetFolderPath.startsWith(draggedPath + '/')) { this.toast(this.t('folders.cannot_move_into_self'), { type: 'warning' }); return; } const folderName = draggedPath.split('/').pop(); const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName; if (newPath === draggedPath) return; // Capture favorites info before async call const oldPrefix = draggedPath + '/'; const newPrefix = newPath + '/'; try { const response = await fetch('/api/folders/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oldPath: draggedPath, newPath }) }); if (response.ok) { // Update favorites for notes inside moved folder const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix)); if (favoritesInFolder.length > 0) { const newFavorites = this.favorites.map(f => f.startsWith(oldPrefix) ? newPrefix + f.substring(oldPrefix.length) : f ); this.favorites = newFavorites; this.favoritesSet = new Set(newFavorites); this.saveFavorites(); } // Keep folder expanded if it was const wasExpanded = this.expandedFolders.has(draggedPath); await this.loadNotes(); await this.loadSharedNotePaths(); if (wasExpanded) { this.expandedFolders.delete(draggedPath); this.expandedFolders.add(newPath); this.saveExpandedFolders(); } } else { const errorData = await response.json().catch(() => ({})); this.toast(errorData.detail || this.t('move.failed_folder'), { type: 'error' }); } } catch (error) { console.error('Failed to move folder:', error); this.toast(this.t('move.failed_folder'), { type: 'error' }); } return; } // Handle note or media drop into folder const item = this.notes.find(n => n.path === draggedPath); if (!item) return; const filename = draggedPath.split('/').pop(); const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename; if (newPath === draggedPath) return; // Check if note is favorited (only for notes) const wasFavorited = isNote && this.favoritesSet.has(draggedPath); try { // Use different endpoint for media vs notes const endpoint = isMedia ? '/api/media/move' : '/api/notes/move'; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oldPath: draggedPath, newPath }) }); if (response.ok) { // Update favorites if the moved note was favorited if (wasFavorited) { const newFavorites = this.favorites.map(f => f === draggedPath ? newPath : f); this.favorites = newFavorites; this.favoritesSet = new Set(newFavorites); this.saveFavorites(); } // Keep current item open if it was the moved one const wasCurrentNote = this.currentNote === draggedPath; const wasCurrentMedia = this.currentMedia === draggedPath; await this.loadNotes(); if (isNote) { await this.loadSharedNotePaths(); } if (wasCurrentNote) this.currentNote = newPath; if (wasCurrentMedia) this.currentMedia = newPath; } else { const errorData = await response.json().catch(() => ({})); const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note'; this.toast(errorData.detail || this.t(errorKey), { type: 'error' }); } } catch (error) { console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error); const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note'; this.toast(this.t(errorKey), { type: 'error' }); } }, // Load a specific note async loadNote(notePath, updateHistory = true, searchQuery = '') { try { // Close mobile sidebar when a note is selected this.mobileSidebarOpen = false; const response = await fetch(`/api/notes/${notePath}`); // Check if note exists if (!response.ok) { if (response.status === 404) { // Note not found - silently redirect to home window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/'); this.currentNote = ''; this.noteContent = ''; if (this.currentMediaType === 'drawing') { this._drawingDisconnectResizeObserver(); } this.currentMedia = ''; document.title = this.appName; return; } throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); this.currentNote = notePath; this._lastRenderedContent = ''; // Clear render cache for new note this._lastRenderedNote = ''; this._cachedRenderedHTML = ''; this._initializedVideoSources = new Set(); // Clear video cache for new note this.noteContent = data.content; this.currentNoteName = notePath.split('/').pop().replace('.md', ''); if (this.currentMediaType === 'drawing') { this._drawingDisconnectResizeObserver(); } this.currentMedia = ''; // Clear image viewer when loading a note this.shareInfo = null; // Reset share info for new note // Update browser tab title document.title = `${this.currentNoteName} - ${this.appName}`; this.lastSaved = false; // Extract outline for TOC panel this.extractOutline(data.content); // Store backlinks from API response this.backlinks = data.backlinks || []; // Initialize undo/redo history for this note (with cursor at start) this.undoHistory = [{ content: data.content, cursorPos: 0 }]; this.redoHistory = []; this.hasPendingHistoryChanges = false; // Update browser URL and history if (updateHistory) { // Encode the path properly (spaces become %20, etc.) const pathWithoutExtension = notePath.replace('.md', ''); // Encode each path segment to handle special characters const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/'); let url = `/${encodedPath}`; // Add search query parameter if present if (searchQuery) { url += `?search=${encodeURIComponent(searchQuery)}`; } window.history.pushState( { notePath: notePath, searchQuery: searchQuery, homepageFolder: this.selectedHomepageFolder || '' // Save current folder state }, '', url ); } // Calculate stats if plugin enabled if (this.statsPluginEnabled) { this.calculateStats(); } // Parse frontmatter metadata this.parseMetadata(); // Store search query for highlighting if (searchQuery) { this.currentSearchHighlight = searchQuery; } else { // Clear highlights if no search query this.currentSearchHighlight = ''; } // Expand folder tree to show the loaded note this.expandFolderForNote(notePath); // Use $nextTick twice to ensure Alpine.js has time to: // 1. First tick: expand folders and update DOM // 2. Second tick: highlight the note and setup everything else this.$nextTick(() => { this.$nextTick(() => { this.refreshDOMCache(); this.setupScrollSync(); this.scrollToTop(); // Apply or clear search highlighting if (searchQuery) { // Pass true to focus editor when loading from search result this.highlightSearchTerm(searchQuery, true); } else { this.clearSearchHighlights(); } // Scroll note into view in sidebar if needed this.scrollNoteIntoView(notePath); }); }); } catch (error) { ErrorHandler.handle('load note', error); } }, // Load item (note or media) from URL path loadItemFromURL() { // Get path from URL (e.g., /folder/note or /folder/image.png) let path = window.location.pathname; // Strip .md extension if present (for MKdocs/Zensical integration) if (path.toLowerCase().endsWith('.md')) { path = path.slice(0, -3); // Update URL bar to show clean path without .md window.history.replaceState(null, '', path); } // Skip if root path or static assets if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) { return; } // Remove leading slash and decode URL encoding (e.g., %20 -> space) const decodedPath = decodeURIComponent(path.substring(1)); // Check if this is a media file (image, audio, video, PDF) const matchedItem = this.notes.find(n => n.path === decodedPath); if (matchedItem && matchedItem.type !== 'note') { // It's a media file, view it this.viewMedia(decodedPath, matchedItem.type, false); // false = don't update history } else { // It's a note, add .md extension and load it const notePath = decodedPath + '.md'; // Parse query string for search parameter const urlParams = new URLSearchParams(window.location.search); const searchParam = urlParams.get('search'); // Try to load the note directly - the backend will handle 404 if it doesn't exist // This is more robust than checking the frontend notes list this.loadNote(notePath, false, searchParam || ''); // If there's a search parameter, populate the search box and trigger search if (searchParam) { this.searchQuery = searchParam; // Trigger search to populate results list this.searchNotes(); } } }, // Highlight search term in editor and preview highlightSearchTerm(query, focusEditor = false) { if (!query || !query.trim()) { this.clearSearchHighlights(); return; } const searchTerm = query.trim(); // Highlight in editor (textarea) this.highlightInEditor(searchTerm, focusEditor); // Highlight in preview (rendered HTML) this.highlightInPreview(searchTerm); }, // Highlight search term in the editor textarea highlightInEditor(searchTerm, shouldFocus = false) { const editor = this._domCache.editor || document.getElementById('editor'); if (!editor) return; // For textarea, we can't directly highlight text, but we can scroll to first match const content = editor.value; const lowerContent = content.toLowerCase(); const lowerTerm = searchTerm.toLowerCase(); const index = lowerContent.indexOf(lowerTerm); if (index !== -1) { // Calculate line number to scroll to const textBefore = content.substring(0, index); const lineNumber = textBefore.split('\n').length; // Scroll to approximate position const lineHeight = 20; // Approximate line height in pixels editor.scrollTop = (lineNumber - 5) * lineHeight; // Scroll a bit above to show context // Only focus and select if explicitly requested (e.g., from search result click) if (shouldFocus) { editor.focus(); editor.setSelectionRange(index, index + searchTerm.length); // Blur immediately so the selection stays visible but editor isn't focused setTimeout(() => editor.blur(), 100); } } }, // Highlight search term in the preview pane highlightInPreview(searchTerm) { const preview = document.querySelector('.markdown-preview'); if (!preview) return; // Remove existing highlights this.clearSearchHighlights(); // Create a tree walker to find all text nodes const walker = document.createTreeWalker( preview, NodeFilter.SHOW_TEXT, null, false ); const textNodes = []; let node; while (node = walker.nextNode()) { // Skip code blocks and pre tags if (node.parentElement.tagName === 'CODE' || node.parentElement.tagName === 'PRE') { continue; } textNodes.push(node); } const lowerTerm = searchTerm.toLowerCase(); let matchIndex = 0; // Highlight matches in text nodes textNodes.forEach(textNode => { const text = textNode.textContent; const lowerText = text.toLowerCase(); if (lowerText.includes(lowerTerm)) { const fragment = document.createDocumentFragment(); let lastIndex = 0; let index; while ((index = lowerText.indexOf(lowerTerm, lastIndex)) !== -1) { // Add text before match if (index > lastIndex) { fragment.appendChild( document.createTextNode(text.substring(lastIndex, index)) ); } // Add highlighted match const mark = document.createElement('mark'); mark.className = 'search-highlight'; mark.setAttribute('data-match-index', matchIndex); mark.textContent = text.substring(index, index + searchTerm.length); // First match is active (styled via CSS) if (matchIndex === 0) { mark.classList.add('active-match'); } fragment.appendChild(mark); matchIndex++; lastIndex = index + searchTerm.length; } // Add remaining text if (lastIndex < text.length) { fragment.appendChild( document.createTextNode(text.substring(lastIndex)) ); } // Replace text node with highlighted fragment textNode.parentNode.replaceChild(fragment, textNode); } }); // Update total matches and reset current index this.totalMatches = matchIndex; this.currentMatchIndex = matchIndex > 0 ? 0 : -1; // Scroll to first match if (this.totalMatches > 0) { this.scrollToMatch(0); } }, // Navigate to next search match nextMatch() { if (this.totalMatches === 0) return; this.currentMatchIndex = (this.currentMatchIndex + 1) % this.totalMatches; this.scrollToMatch(this.currentMatchIndex); }, // Navigate to previous search match previousMatch() { if (this.totalMatches === 0) return; this.currentMatchIndex = (this.currentMatchIndex - 1 + this.totalMatches) % this.totalMatches; this.scrollToMatch(this.currentMatchIndex); }, // Scroll to a specific match index scrollToMatch(index) { const preview = document.querySelector('.markdown-preview'); if (!preview) return; const allMatches = preview.querySelectorAll('mark.search-highlight'); if (index < 0 || index >= allMatches.length) return; // Update styling - make current match prominent (via CSS class) allMatches.forEach((mark, i) => { mark.classList.toggle('active-match', i === index); }); // Scroll to the match const targetMatch = allMatches[index]; const previewContainer = this._domCache.previewContainer; if (previewContainer && targetMatch) { const elementTop = targetMatch.offsetTop; previewContainer.scrollTop = elementTop - 100; // Scroll with some offset } }, // Clear search highlights clearSearchHighlights() { const preview = document.querySelector('.markdown-preview'); if (!preview) return; const highlights = preview.querySelectorAll('mark.search-highlight'); highlights.forEach(mark => { const text = document.createTextNode(mark.textContent); mark.parentNode.replaceChild(text, mark); }); // Normalize text nodes to merge adjacent text nodes preview.normalize(); // Reset match counters this.totalMatches = 0; this.currentMatchIndex = -1; }, // ===================================================== // DROPDOWN MENU SYSTEM // ===================================================== toggleNewDropdown(event) { this.showNewDropdown = true; // Always open (or keep open) if (event && event.target) { const rect = event.target.getBoundingClientRect(); // Position dropdown next to the clicked element let top = rect.bottom + 4; // 4px spacing let left = rect.left; // Keep dropdown on screen const dropdownWidth = 200; const dropdownHeight = 150; if (left + dropdownWidth > window.innerWidth) { left = rect.right - dropdownWidth; } if (top + dropdownHeight > window.innerHeight) { top = rect.top - dropdownHeight - 4; } this.dropdownPosition = { top, left }; } }, closeDropdown() { this.showNewDropdown = false; this.dropdownTargetFolder = null; // Reset folder context }, /** * Parent folder for new note/folder/drawing from the + menu when no explicit path is passed. * Same rules as the create-name modal: '' = vault root; otherwise a folder path (e.g. folder1/sub). */ inferredNewItemTargetFolder() { if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) { return this.dropdownTargetFolder; } return this.selectedHomepageFolder || ''; }, // ===================================================== // UNIFIED CREATION FUNCTIONS (reusable from anywhere) // ===================================================== // Switch to split view (if in preview-only mode) and focus editor for new notes focusEditorForNewNote() { // Only switch if in preview-only mode - don't disturb edit or split mode if (this.viewMode === 'preview') { this.viewMode = 'split'; this.saveViewMode(); } // Focus the editor after a short delay to ensure DOM is updated this.$nextTick(() => { const editor = document.getElementById('note-editor'); if (editor) editor.focus(); }); }, async createNote(folderPath = null, directPath = null) { if (directPath) { const notePath = directPath.endsWith('.md') ? directPath : `${directPath}.md`; const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { this.toast(this.t('notes.already_exists', { name: notePath }), { type: 'warning' }); return; } await this._finalizeCreateNote(notePath); return; } const explicitTarget = folderPath !== null && folderPath !== undefined ? folderPath : undefined; this.openCreateNameModal('note', explicitTarget); }, /** * @param {'note'|'folder'} kind * @param {string|undefined} explicitTargetFolder - if set, use as parent folder context ("" = root) */ openCreateNameModal(kind, explicitTargetFolder = undefined) { const targetFolder = explicitTargetFolder !== undefined ? explicitTargetFolder : this.inferredNewItemTargetFolder(); this.closeDropdown(); this.mobileSidebarOpen = false; this.createNameModalKind = kind; this.createNameModalTargetFolder = targetFolder; this.createNameModalInput = ''; this.showCreateNameModal = true; this.$nextTick(() => { const el = document.getElementById('create-name-modal-input'); if (el) { el.focus(); el.select(); } }); }, closeCreateNameModal() { this.showCreateNameModal = false; this.createNameModalInput = ''; }, cancelCreateNameModal() { this.closeCreateNameModal(); }, createNameModalHelpText() { const tf = this.createNameModalTargetFolder; if (this.createNameModalKind === 'note') { return tf ? this.t('notes.prompt_name_in_folder', { folder: tf }) : this.t('notes.prompt_name_with_path'); } return tf ? this.t('folders.prompt_name_in_folder', { folder: tf }) : this.t('folders.prompt_name_with_path'); }, createNameModalTitle() { return this.createNameModalKind === 'note' ? this.t('sidebar.new_note') : this.t('sidebar.new_folder'); }, createNameModalLabel() { return this.createNameModalKind === 'note' ? this.t('notes.prompt_name') : this.t('folders.prompt_name'); }, async submitCreateNameModal() { const rawName = (this.createNameModalInput || '').trim(); if (!rawName) { this.closeCreateNameModal(); return; } const targetFolder = this.createNameModalTargetFolder; const kind = this.createNameModalKind; if (kind === 'note') { const validation = targetFolder ? FilenameValidator.validateFilename(rawName) : FilenameValidator.validatePath(rawName); if (!validation.valid) { this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' }); return; } const validatedName = validation.sanitized; const notePath = targetFolder ? `${targetFolder}/${validatedName}.md` : (validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`); const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { this.toast(this.t('notes.already_exists', { name: notePath }), { type: 'warning' }); return; } const ok = await this._finalizeCreateNote(notePath); if (ok) this.closeCreateNameModal(); return; } const validation = targetFolder ? FilenameValidator.validateFilename(rawName) : FilenameValidator.validatePath(rawName); if (!validation.valid) { this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' }); return; } const validatedName = validation.sanitized; const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName; const existingFolder = this.allFolders.find(folder => folder === folderPath); if (existingFolder) { this.toast(this.t('folders.already_exists', { name: validatedName }), { type: 'warning' }); return; } const ok = await this._finalizeCreateFolder(folderPath, targetFolder); if (ok) this.closeCreateNameModal(); }, async _finalizeCreateNote(notePath) { try { const response = await fetch(`/api/notes/${notePath}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: '' }) }); if (response.ok) { const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : ''; if (folderPart) this.expandedFolders.add(folderPart); await this.loadNotes(); await this.loadNote(notePath); this.focusEditorForNewNote(); return true; } ErrorHandler.handle('create note', new Error('Server returned error')); return false; } catch (error) { ErrorHandler.handle('create note', error); return false; } }, async _finalizeCreateFolder(folderPath, targetFolder) { try { const response = await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: folderPath }) }); if (response.ok) { if (targetFolder) { this.expandedFolders.add(targetFolder); } this.expandedFolders.add(folderPath); await this.loadNotes(); this.goToHomepageFolder(folderPath); return true; } ErrorHandler.handle('create folder', new Error('Server returned error')); return false; } catch (error) { ErrorHandler.handle('create folder', error); return false; } }, async createFolder(parentPath = null) { const explicitTarget = parentPath !== null && parentPath !== undefined ? parentPath : undefined; this.openCreateNameModal('folder', explicitTarget); }, renameFolder(folderPath, currentName) { this.renameFolderPath = folderPath; this.renameFolderOldName = currentName; this.renameFolderInput = currentName; this.showRenameFolderModal = true; this.mobileSidebarOpen = false; this.$nextTick(() => { const el = document.getElementById('rename-folder-modal-input'); if (el) { el.focus(); el.select(); } }); }, closeRenameFolderModal() { this.showRenameFolderModal = false; this.renameFolderPath = ''; this.renameFolderOldName = ''; this.renameFolderInput = ''; }, cancelRenameFolderModal() { this.closeRenameFolderModal(); }, /** * Theme-styled confirm dialog (replaces window.confirm). * @param {{ message: string, title?: string, danger?: boolean, confirmLabel?: string, cancelLabel?: string }} options * @returns {Promise} */ confirmModalAsk(options = {}) { const { message, title, danger = true, confirmLabel, cancelLabel } = options; return new Promise((resolve) => { this.confirmModalTitle = title || this.t('common.confirm_title'); this.confirmModalMessage = message || ''; this.confirmModalDanger = danger; this.confirmModalConfirmLabel = confirmLabel || (danger ? this.t('common.delete') : this.t('common.ok')); this.confirmModalCancelLabel = cancelLabel || this.t('common.cancel'); this._confirmModalResolve = resolve; this.showConfirmModal = true; this.mobileSidebarOpen = false; }); }, confirmModalConfirm() { this._confirmModalFinish(true); }, confirmModalCancel() { this._confirmModalFinish(false); }, _confirmModalFinish(ok) { if (!this._confirmModalResolve) return; this.showConfirmModal = false; const fn = this._confirmModalResolve; this._confirmModalResolve = null; fn(!!ok); }, async submitRenameFolderModal() { const folderPath = this.renameFolderPath; const currentName = this.renameFolderOldName; const newName = (this.renameFolderInput || '').trim(); if (!newName || newName === currentName) { this.closeRenameFolderModal(); return; } const validation = FilenameValidator.validateFilename(newName); if (!validation.valid) { this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' }); return; } const validatedName = validation.sanitized; const pathParts = folderPath.split('/'); pathParts[pathParts.length - 1] = validatedName; const newPath = pathParts.join('/'); const ok = await this._finalizeRenameFolder(folderPath, newPath); if (ok) this.closeRenameFolderModal(); }, async _finalizeRenameFolder(folderPath, newPath) { try { const response = await fetch('/api/folders/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oldPath: folderPath, newPath: newPath }) }); if (response.ok) { if (this.expandedFolders.has(folderPath)) { this.expandedFolders.delete(folderPath); this.expandedFolders.add(newPath); } const folderPrefix = folderPath + '/'; const newFolderPrefix = newPath + '/'; const newFavorites = this.favorites.map(f => { if (f.startsWith(folderPrefix)) { return f.replace(folderPrefix, newFolderPrefix); } return f; }); if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) { this.favorites = newFavorites; this.favoritesSet = new Set(newFavorites); this.saveFavorites(); } if (this.currentNote && this.currentNote.startsWith(folderPrefix)) { this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix); } await this.loadNotes(); return true; } ErrorHandler.handle('rename folder', new Error('Server returned error')); return false; } catch (error) { ErrorHandler.handle('rename folder', error); return false; } }, // Delete folder async deleteFolder(folderPath, folderName) { const ok = await this.confirmModalAsk({ message: this.t('folders.confirm_delete', { name: folderName }), }); if (!ok) return; try { const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); if (response.ok) { // Remove from expanded folders this.expandedFolders.delete(folderPath); // Remove any favorites that were in the deleted folder const folderPrefix = folderPath + '/'; const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix)); if (newFavorites.length !== this.favorites.length) { this.favorites = newFavorites; this.favoritesSet = new Set(newFavorites); this.saveFavorites(); } // Clear current note if it was in the deleted folder if (this.currentNote && this.currentNote.startsWith(folderPrefix)) { this.currentNote = ''; this.noteContent = ''; document.title = this.appName; } await this.loadNotes(); } else { ErrorHandler.handle('delete folder', new Error('Server returned error')); } } catch (error) { ErrorHandler.handle('delete folder', error); } }, // Auto-save with debounce autoSave() { if (this.saveTimeout) { clearTimeout(this.saveTimeout); } this.lastSaved = false; // Push to undo history (but not during undo/redo operations) if (!this.isUndoRedo) { this.pushToHistory(); } // Calculate stats in real-time if plugin enabled if (this.statsPluginEnabled) { this.calculateStats(); } // Parse metadata in real-time this.parseMetadata(); // Update outline (TOC) in real-time this.extractOutline(this.noteContent); this.saveTimeout = setTimeout(() => { // Commit to undo history when autosave triggers (same debounce timing) if (this.hasPendingHistoryChanges) { this.commitToHistory(); } this.saveNote(); }, CONFIG.AUTOSAVE_DELAY); }, // Mark that we have pending changes (called on each keystroke) pushToHistory() { this.hasPendingHistoryChanges = true; }, // Immediately commit pending changes to history (call before undo/redo) flushHistory() { if (this.hasPendingHistoryChanges) { this.commitToHistory(); } }, // Actually commit to undo history (internal) commitToHistory() { const editor = document.getElementById('note-editor'); const cursorPos = editor ? editor.selectionStart : 0; // Only push if content actually changed from last history entry if (this.undoHistory.length > 0 && this.undoHistory[this.undoHistory.length - 1].content === this.noteContent) { this.hasPendingHistoryChanges = false; return; } this.undoHistory.push({ content: this.noteContent, cursorPos }); // Limit history size if (this.undoHistory.length > this.maxHistorySize) { this.undoHistory.shift(); } // Clear redo history when new change is made this.redoHistory = []; this.hasPendingHistoryChanges = false; }, // Undo last change undo() { if (!this.currentNote) return; // Flush any pending history changes first (so we don't lose unsaved edits) this.flushHistory(); if (this.undoHistory.length <= 1) return; const editor = document.getElementById('note-editor'); // Pop current state to redo history const currentState = this.undoHistory.pop(); this.redoHistory.push(currentState); // Get previous state const previousState = this.undoHistory[this.undoHistory.length - 1]; // Apply previous state this.isUndoRedo = true; this.noteContent = previousState.content; // Recalculate stats with new content if (this.statsPluginEnabled) { this.calculateStats(); } // Restore cursor position from the state we're going back to this.$nextTick(() => { this.saveNote(); this.isUndoRedo = false; if (editor) { setTimeout(() => { const newPos = Math.min(previousState.cursorPos, this.noteContent.length); editor.setSelectionRange(newPos, newPos); editor.focus(); }, 0); } }); }, // Redo last undone change redo() { if (!this.currentNote) return; // Flush any pending history changes first this.flushHistory(); if (this.redoHistory.length === 0) return; const editor = document.getElementById('note-editor'); // Pop from redo history const nextState = this.redoHistory.pop(); // Push to undo history this.undoHistory.push(nextState); // Apply next state this.isUndoRedo = true; this.noteContent = nextState.content; // Recalculate stats with new content if (this.statsPluginEnabled) { this.calculateStats(); } // Restore cursor position from the state we're going forward to this.$nextTick(() => { this.saveNote(); this.isUndoRedo = false; if (editor) { setTimeout(() => { const newPos = Math.min(nextState.cursorPos, this.noteContent.length); editor.setSelectionRange(newPos, newPos); editor.focus(); }, 0); } }); }, // Markdown formatting helpers wrapSelection(before, after, placeholder) { const editor = document.getElementById('note-editor'); if (!editor) return; const start = editor.selectionStart; const end = editor.selectionEnd; const selectedText = this.noteContent.substring(start, end); const textToWrap = selectedText || placeholder; // Build the new text const newText = before + textToWrap + after; // Update content this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end); // Set cursor position (select the wrapped text or placeholder) this.$nextTick(() => { if (selectedText) { // If text was selected, keep it selected (inside the wrapper) editor.setSelectionRange(start + before.length, start + before.length + selectedText.length); } else { // If no text selected, select the placeholder editor.setSelectionRange(start + before.length, start + before.length + placeholder.length); } editor.focus(); }); // Trigger autosave this.autoSave(); }, insertLink() { const editor = document.getElementById('note-editor'); if (!editor) return; const start = editor.selectionStart; const end = editor.selectionEnd; const selectedText = this.noteContent.substring(start, end); // If text is selected, use it as link text; otherwise use placeholder const linkText = selectedText || 'link text'; const linkUrl = 'url'; // Build the markdown link const newText = `[${linkText}](${linkUrl})`; // Update content this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end); // Set cursor position to select the URL part for easy editing this.$nextTick(() => { const urlStart = start + linkText.length + 3; // After "[linkText](" const urlEnd = urlStart + linkUrl.length; editor.setSelectionRange(urlStart, urlEnd); editor.focus(); }); // Trigger autosave this.autoSave(); }, // Insert a markdown table placeholder insertTable() { const editor = document.getElementById('note-editor'); if (!editor) return; const cursorPos = editor.selectionStart; // Basic 3x3 table placeholder const table = `| Header 1 | Header 2 | Header 3 | |----------|----------|----------| | Cell 1 | Cell 2 | Cell 3 | | Cell 4 | Cell 5 | Cell 6 | `; // Add newline before if not at start of line const textBefore = this.noteContent.substring(0, cursorPos); const needsNewlineBefore = textBefore.length > 0 && !textBefore.endsWith('\n'); const prefix = needsNewlineBefore ? '\n\n' : ''; // Insert the table this.noteContent = textBefore + prefix + table + this.noteContent.substring(cursorPos); // Position cursor at first header for easy editing this.$nextTick(() => { const newPos = cursorPos + prefix.length + 2; // After "| " editor.setSelectionRange(newPos, newPos + 8); // Select "Header 1" editor.focus(); }); // Trigger autosave this.autoSave(); }, // Format selected text or insert formatting at cursor formatText(type) { // Simple wrap cases - reuse wrapSelection() const wrapFormats = { 'bold': ['**', '**', 'bold'], 'italic': ['*', '*', 'italic'], 'strikethrough': ['~~', '~~', 'strikethrough'], 'code': ['`', '`', 'code'] }; if (wrapFormats[type]) { const [before, after, placeholder] = wrapFormats[type]; this.wrapSelection(before, after, placeholder); return; } // Special cases that need custom handling switch (type) { case 'heading': this.insertLinePrefix('## ', 'Heading'); break; case 'quote': this.insertLinePrefix('> ', 'quote'); break; case 'bullet': this.insertLinePrefix('- ', 'item'); break; case 'numbered': this.insertLinePrefix('1. ', 'item'); break; case 'checkbox': this.insertLinePrefix('- [ ] ', 'task'); break; case 'link': this.insertLink(); break; case 'image': this.wrapSelection('![', '](image-url)', 'alt text'); break; case 'codeblock': this.wrapSelection('```\n', '\n```', 'code'); break; case 'table': this.insertTable(); break; } }, // Insert a line prefix (for headings, lists, quotes) insertLinePrefix(prefix, placeholder) { const editor = document.getElementById('note-editor'); if (!editor) return; const start = editor.selectionStart; const end = editor.selectionEnd; const selectedText = this.noteContent.substring(start, end); const beforeText = this.noteContent.substring(0, start); const afterText = this.noteContent.substring(end); // Check if at start of line const atLineStart = beforeText.endsWith('\n') || beforeText === ''; const newline = atLineStart ? '' : '\n'; let replacement; if (selectedText) { // Prefix each line of selection replacement = newline + selectedText.split('\n').map((line, i) => { // For numbered lists, increment the number if (prefix === '1. ') return `${i + 1}. ${line}`; return prefix + line; }).join('\n'); } else { replacement = newline + prefix + placeholder; } this.noteContent = beforeText + replacement + afterText; this.$nextTick(() => { if (selectedText) { editor.setSelectionRange(start + newline.length, start + replacement.length); } else { const placeholderStart = start + newline.length + prefix.length; editor.setSelectionRange(placeholderStart, placeholderStart + placeholder.length); } editor.focus(); }); this.autoSave(); }, // Save current note async saveNote() { if (!this.currentNote) return; this.isSaving = true; try { const response = await fetch(`/api/notes/${this.currentNote}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: this.noteContent }) }); if (response.ok) { this.lastSaved = true; // Update only the modified timestamp for the current note (no full reload needed) const note = this.notes.find(n => n.path === this.currentNote); if (note) { note.modified = new Date().toISOString(); note.size = new Blob([this.noteContent]).size; // Parse tags from content note.tags = this.parseTagsFromContent(this.noteContent); } // Reload tags to update sidebar counts (debounced to prevent spam) this.loadTagsDebounced(); // Rebuild folder tree if tag filters are active if (this.selectedTags.length > 0) { this.buildFolderTree(); } // Hide "saved" indicator setTimeout(() => { this.lastSaved = false; }, CONFIG.SAVE_INDICATOR_DURATION); } else { ErrorHandler.handle('save note', new Error('Server returned error')); } } catch (error) { ErrorHandler.handle('save note', error); } finally { this.isSaving = false; } }, // Rename current note async renameNote() { if (!this.currentNote) return; const oldPath = this.currentNote; const newName = this.currentNoteName.trim(); if (!newName) { this.toast(this.t('notes.empty_name'), { type: 'warning' }); return; } // Validate the new name (single segment, no path separators) const validation = FilenameValidator.validateFilename(newName); if (!validation.valid) { this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' }); // Reset the name in the UI this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); return; } const validatedName = validation.sanitized; const folder = oldPath.split('/').slice(0, -1).join('/'); const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`; if (oldPath === newPath) return; // Check if a note with the new name already exists const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase()); if (existingNote) { this.toast(this.t('notes.already_exists', { name: validatedName }), { type: 'warning' }); // Reset the name in the UI this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); return; } // Create new note with same content try { const response = await fetch(`/api/notes/${newPath}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: this.noteContent }) }); if (response.ok) { // Delete old note await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' }); // Update favorites if the renamed note was favorited if (this.favoritesSet.has(oldPath)) { const newFavorites = this.favorites.map(f => f === oldPath ? newPath : f); this.favorites = newFavorites; this.favoritesSet = new Set(newFavorites); this.saveFavorites(); } this.currentNote = newPath; await this.loadNotes(); } else { ErrorHandler.handle('rename note', new Error('Server returned error')); } } catch (error) { ErrorHandler.handle('rename note', error); } }, // Delete current note async deleteCurrentNote() { if (!this.currentNote) return; // Just call deleteNote with current note details await this.deleteNote(this.currentNote, this.currentNoteName); }, // Delete any note from sidebar async deleteNote(notePath, noteName) { const ok = await this.confirmModalAsk({ message: this.t('notes.confirm_delete', { name: noteName }), }); if (!ok) return; try { const response = await fetch(`/api/notes/${notePath}`, { method: 'DELETE' }); if (response.ok) { // Remove from favorites if it was favorited if (this.favoritesSet.has(notePath)) { const newFavorites = this.favorites.filter(f => f !== notePath); this.favorites = newFavorites; this.favoritesSet = new Set(newFavorites); this.saveFavorites(); } // If the deleted note is currently open, clear it if (this.currentNote === notePath) { this.currentNote = ''; this.noteContent = ''; this.currentNoteName = ''; this._lastRenderedContent = ''; // Clear render cache this._lastRenderedNote = ''; this._cachedRenderedHTML = ''; document.title = this.appName; // Redirect to root window.history.replaceState({}, '', '/'); } await this.loadNotes(); } else { ErrorHandler.handle('delete note', new Error('Server returned error')); } } catch (error) { ErrorHandler.handle('delete note', error); } }, // Search notes debouncedSearchNotes() { if (this.searchDebounceTimeout) { clearTimeout(this.searchDebounceTimeout); } const hasTextSearch = this.searchQuery.trim().length > 0; if (!hasTextSearch) { this.isSearching = false; this.searchNotes(); return; } this.isSearching = true; this.searchResults = []; this.searchDebounceTimeout = setTimeout(() => { this.searchNotes(); }, CONFIG.SEARCH_DEBOUNCE_DELAY); }, // Search notes by text (calls unified filter logic) async searchNotes() { await this.applyFilters(); }, // Trigger MathJax typesetting after DOM update typesetMath() { if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) { // Use a small delay to ensure DOM is updated setTimeout(() => { const previewContent = document.querySelector('.markdown-preview'); if (previewContent) { MathJax.typesetPromise([previewContent]).catch((err) => { console.error('MathJax typesetting failed:', err); }); } }, 10); } }, // Render Mermaid diagrams async renderMermaid() { if (typeof window.mermaid === 'undefined') { console.warn('Mermaid not loaded yet'); return; } // Use requestAnimationFrame for better performance than setTimeout requestAnimationFrame(async () => { const previewContent = document.querySelector('.markdown-preview'); if (!previewContent) return; // Get the appropriate theme based on current app theme const themeType = this.getThemeType(); const mermaidTheme = themeType === 'light' ? 'default' : 'dark'; // Only reinitialize if theme changed (performance optimization) if (this.lastMermaidTheme !== mermaidTheme) { window.mermaid.initialize({ startOnLoad: false, theme: mermaidTheme, securityLevel: 'strict', // Use strict for better security fontFamily: 'inherit', // v11 changed useMaxWidth defaults - restore responsive behavior flowchart: { useMaxWidth: true }, sequence: { useMaxWidth: true }, gantt: { useMaxWidth: true }, journey: { useMaxWidth: true }, timeline: { useMaxWidth: true }, class: { useMaxWidth: true }, state: { useMaxWidth: true }, er: { useMaxWidth: true }, pie: { useMaxWidth: true }, quadrantChart: { useMaxWidth: true }, requirement: { useMaxWidth: true }, mindmap: { useMaxWidth: true }, gitGraph: { useMaxWidth: true } }); this.lastMermaidTheme = mermaidTheme; } // Find all code blocks with language 'mermaid' const mermaidBlocks = previewContent.querySelectorAll('pre code.language-mermaid'); // Early return if no diagrams to render if (mermaidBlocks.length === 0) return; for (let i = 0; i < mermaidBlocks.length; i++) { const block = mermaidBlocks[i]; const pre = block.parentElement; // Skip if already rendered (performance optimization) if (pre.querySelector('.mermaid-rendered')) continue; try { const code = block.textContent; const id = `mermaid-diagram-${Date.now()}-${i}`; // Render the diagram const { svg } = await window.mermaid.render(id, code); // Create a container for the rendered diagram 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; // Store original code for theme re-rendering container.dataset.originalCode = code; // Replace the code block with the rendered diagram pre.parentElement.replaceChild(container, pre); } catch (error) { console.error('Mermaid rendering error:', error); // Add error indicator to the code block const errorMsg = document.createElement('div'); errorMsg.style.cssText = 'color: var(--error); padding: 10px; border-left: 3px solid var(--error); margin-top: 10px;'; errorMsg.textContent = `⚠️ Mermaid diagram error: ${error.message}`; pre.parentElement.insertBefore(errorMsg, pre.nextSibling); } } }); }, // Get current theme type (light or dark) // Returns: 'light' or 'dark' // Used by features that need to adapt to theme brightness (e.g., Mermaid diagrams, Chart.js) getThemeType() { // Handle system theme if (this.currentTheme === 'system') { const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; return isDark ? 'dark' : 'light'; } // Try to get theme type from loaded theme metadata const currentThemeData = this.availableThemes.find(t => t.id === this.currentTheme); if (currentThemeData && currentThemeData.type) { // Use metadata from theme file (light or dark) return currentThemeData.type; // Already 'light' or 'dark' } // Backward compatibility: fallback to hardcoded map if metadata not available const fallbackMap = { 'light': 'light', 'vs-blue': 'light' }; return fallbackMap[this.currentTheme] || 'dark'; }, // Computed property for rendered markdown get renderedMarkdown() { if (!this.noteContent) return '

Nothing to preview yet...

'; // Performance: Return cached HTML if content hasn't changed if (this.noteContent === this._lastRenderedContent && this.currentNote === this._lastRenderedNote && this._cachedRenderedHTML) { return this._cachedRenderedHTML; } // 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 // BUT we need to protect code blocks first to avoid converting [[text]] inside code const self = this; // Reference for closure // Step 1: Temporarily replace code blocks and inline code with placeholders const codeBlocks = []; // Protect fenced code blocks (```...```) contentToRender = contentToRender.replace(/```[\s\S]*?```/g, (match) => { codeBlocks.push(match); return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`; }); // Protect inline code (`...`) contentToRender = contentToRender.replace(/`[^`]+`/g, (match) => { codeBlocks.push(match); return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`; }); // Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]] // Must be before note wikilinks to prevent [[file.png]] from being matched first contentToRender = contentToRender.replace( /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, mediaName, altText) => { const filename = mediaName.trim(); const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, ''); // Resolve media path using O(1) lookup const mediaPath = self.resolveMediaWikilink(filename); if (mediaPath) { // URL-encode path segments for the API const encodedPath = mediaPath.split('/').map(segment => { try { return encodeURIComponent(decodeURIComponent(segment)); } catch (e) { return encodeURIComponent(segment); } }).join('/'); const safeAlt = alt.replace(/"/g, '"'); const mediaSrc = `/api/media/${encodedPath}`; const mediaType = self.getMediaType(filename); // Return appropriate HTML based on media type switch (mediaType) { case 'audio': return `
${safeAlt}
`; case 'video': return `
`; case 'document': // Local PDFs: show iframe preview return `
`; default: // image return `${safeAlt}`; } } // Media not found - return broken indicator const safeFilename = filename.replace(/&/g, '&').replace(//g, '>'); const mediaType = self.getMediaType(filename); const icon = mediaType === 'audio' ? '🎡' : mediaType === 'video' ? '🎬' : mediaType === 'document' ? 'πŸ“„' : 'πŸ–ΌοΈ'; return `${icon} ${safeFilename}`; } ); // Step 2b: Convert note wikilinks: [[note]] or [[note|display text]] contentToRender = contentToRender.replace( /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, target, displayText) => { const linkTarget = target.trim(); const linkText = displayText ? displayText.trim() : linkTarget; // Fast O(1) check using pre-built lookup maps // Handle section anchors: extract base note path const hashIndex = linkTarget.indexOf('#'); const basePath = hashIndex !== -1 ? linkTarget.substring(0, hashIndex) : linkTarget; const noteExists = basePath === '' || self.wikiLinkExists(basePath); // 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}`; } ); // Step 3: Restore code blocks contentToRender = contentToRender.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => { 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, 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); // Sanitize HTML to prevent XSS attacks // DOMPurify defaults allow most HTML/SVG tags but strip scripts, iframes, and event handlers // MathJax and Mermaid run AFTER this, so their elements don't need whitelisting html = DOMPurify.sanitize(html); // 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 transform paths for display // Also convert non-image media (audio, video, PDF) to appropriate elements const images = tempDiv.querySelectorAll('img'); images.forEach(img => { let src = img.getAttribute('src'); if (src) { const isExternal = src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'); const isLocal = !isExternal && !src.startsWith('data:'); // Transform relative paths to /api/media/ for serving if (isLocal && !src.startsWith('/api/media/')) { const vaultRelative = self.currentNote ? self.resolveMarkdownMediaPathForNote(self.currentNote, src) : src.split('#')[0].split('?')[0]; const encodedPath = self.encodeVaultRelativePathForMediaApi(vaultRelative); src = `/api/media/${encodedPath}`; img.setAttribute('src', src); } // Check if this is non-image media and convert to appropriate element const mediaType = self.getMediaType(src); const altText = img.getAttribute('alt') || src.split('/').pop().replace(/\.[^/.]+$/, ''); const safeAlt = altText.replace(/"/g, '"'); // Only convert LOCAL media to embedded elements (security) // External non-image media gets styled links instead if (isLocal || src.startsWith('/api/media/')) { if (mediaType === 'audio') { const wrapper = document.createElement('div'); wrapper.className = 'media-embed media-audio'; wrapper.innerHTML = `${safeAlt}`; img.replaceWith(wrapper); return; } else if (mediaType === 'video') { const wrapper = document.createElement('div'); wrapper.className = 'media-embed media-video'; wrapper.innerHTML = ``; img.replaceWith(wrapper); return; } else if (mediaType === 'document') { // Local PDFs: show iframe preview const wrapper = document.createElement('div'); wrapper.className = 'media-embed media-pdf'; wrapper.innerHTML = ``; img.replaceWith(wrapper); return; } } else if (isExternal && mediaType === 'document') { // External PDFs: styled link (opens in new tab) const link = document.createElement('a'); link.href = src; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.className = 'pdf-link'; link.title = `Open ${safeAlt}`; link.innerHTML = `πŸ“„ ${safeAlt}Opens in new tab`; img.replaceWith(link); return; } // External audio/video: leave as broken image for security } // For regular images, set title attribute const altText = img.getAttribute('alt'); if (altText) { img.setAttribute('title', altText); } }); html = tempDiv.innerHTML; // Debounced MathJax rendering (avoid re-running on every keystroke) if (this._mathDebounceTimeout) clearTimeout(this._mathDebounceTimeout); this._mathDebounceTimeout = setTimeout(() => this.typesetMath(), 300); // Debounced Mermaid rendering if (this._mermaidDebounceTimeout) clearTimeout(this._mermaidDebounceTimeout); this._mermaidDebounceTimeout = setTimeout(() => this.renderMermaid(), 300); // 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); } }); // Enable video metadata loading (for first frame preview) // Track by source URL to prevent duplicate requests on re-renders if (!this._initializedVideoSources) this._initializedVideoSources = new Set(); previewEl.querySelectorAll('video[preload="none"]').forEach((video) => { const src = video.getAttribute('src'); if (src && !this._initializedVideoSources.has(src)) { this._initializedVideoSources.add(src); video.preload = 'metadata'; } }); } }, 0); // Cache the result for performance this._lastRenderedContent = this.noteContent; this._lastRenderedNote = this.currentNote; this._cachedRenderedHTML = html; 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) { // Extract language from code element class (e.g., "language-toml" -> "TOML") const codeElement = preElement.querySelector('code'); let language = ''; if (codeElement && codeElement.className) { const match = codeElement.className.match(/language-(\w+)/); if (match) { const langMap = { 'js': 'JavaScript', 'ts': 'TypeScript', 'py': 'Python', 'rb': 'Ruby', 'cs': 'C#', 'cpp': 'C++', 'sh': 'Shell', 'bash': 'Bash', 'zsh': 'Zsh', 'yml': 'YAML', 'md': 'Markdown' }; const rawLang = match[1].toLowerCase(); language = langMap[rawLang] || match[1].toUpperCase(); } } // Create copy button with language label const button = document.createElement('button'); button.className = 'copy-code-button'; const displayText = language || this.t('common.copy_to_clipboard').split(' ')[0]; // Use first word as fallback button.innerHTML = `${displayText}`; button.dataset.originalText = displayText; // Store for restore after copy button.title = this.t('common.copy_to_clipboard'); // Style the button button.style.position = 'absolute'; button.style.top = '8px'; button.style.right = '8px'; button.style.padding = '4px 10px'; 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'; button.style.fontSize = '11px'; button.style.fontWeight = '600'; button.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; button.style.textTransform = 'uppercase'; button.style.letterSpacing = '0.5px'; // 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; const originalText = button.dataset.originalText; const copiedText = this.t('common.copied'); const copyTitle = this.t('common.copy_to_clipboard'); try { await navigator.clipboard.writeText(code); // Visual feedback - show localized "Copied!" button.innerHTML = `${copiedText}`; button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; button.title = copiedText; // Reset after 2 seconds setTimeout(() => { button.innerHTML = `${originalText}`; button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; button.title = copyTitle; }, 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 = `${copiedText}`; button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; setTimeout(() => { button.innerHTML = `${originalText}`; 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') { let date; // Parse date-only strings (YYYY-MM-DD) as local dates to avoid timezone issues if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { const [year, month, day] = value.split('-').map(Number); date = new Date(year, month - 1, day); // month is 0-indexed } else { date = new Date(value); } if (!isNaN(date.getTime())) { return date.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' }); } } // Booleans if (typeof value === 'boolean') { return value ? this.t('common.yes') : this.t('common.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]; }, // Save sidebar width to localStorage saveSidebarWidth() { localStorage.setItem('sidebarWidth', this.sidebarWidth.toString()); }, // Save view mode to localStorage saveViewMode() { try { localStorage.setItem('viewMode', this.viewMode); } catch (error) { console.error('Error saving view mode:', 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'; } }, // 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 via backend API async exportToHTML() { if (!this.currentNote || !this.noteContent) { this.toast(this.t('notes.no_content'), { type: 'info' }); return; } try { // Build API URL with current theme const currentTheme = this.currentTheme || 'light'; const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/'); const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}`; // 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'); } // 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]; } } // Download as blob const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); // Cleanup URL.revokeObjectURL(blobUrl); document.body.removeChild(a); } catch (error) { console.error('HTML export failed:', error); this.toast(this.t('export.failed', { error: error.message }), { type: 'error' }); } }, // Open print preview in new window printPreview() { if (!this.currentNote || !this.noteContent) { this.toast(this.t('notes.no_content'), { type: 'info' }); 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; // Build the full URL const pathWithoutExtension = this.currentNote.replace('.md', ''); const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/'); const url = `${window.location.origin}/${encodedPath}`; try { await navigator.clipboard.writeText(url); } catch (error) { // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = url; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); } // Show brief "Copied!" feedback this.linkCopied = true; setTimeout(() => { this.linkCopied = false; }, 1500); }, // ============================================================================ // Share Functions // ============================================================================ // Load list of shared note paths (for visual indicators and Shared sidebar panel) async loadSharedNotePaths() { try { const response = await fetch('/api/shared-notes'); if (response.ok) { const data = await response.json(); this._sharedNotePaths = new Set(data.paths || []); this._syncSharedNotePathsList(); } } catch (error) { console.error('Failed to load shared note paths:', error); this._sharedNotePaths = new Set(); this._sharedNotePathsList = []; } }, _syncSharedNotePathsList() { this._sharedNotePathsList = [...this._sharedNotePaths].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }) ); }, /** Display rows for Shared notes panel: name + folder line (search-style) */ getSharedPanelItems() { return this._sharedNotePathsList.map((path) => { const noMd = path.replace(/\.md$/i, ''); const last = Math.max(noMd.lastIndexOf('/'), noMd.lastIndexOf('\\')); const name = last < 0 ? noMd : noMd.slice(last + 1); const folder = last < 0 ? '' : noMd.slice(0, last).replace(/\\/g, '/'); return { path, name, folder: folder || 'Root' }; }); }, // Check if a note is currently shared (O(1) lookup) isNoteShared(notePath) { return this._sharedNotePaths.has(notePath); }, // ============================================ // Quick Switcher (Ctrl+Alt+P) // ============================================ openQuickSwitcher() { this.showQuickSwitcher = true; this.quickSwitcherQuery = ''; this.quickSwitcherIndex = 0; // Populate initial results this.quickSwitcherResults = (this.allNotes || []).slice(0, 10); // Focus the input after the modal renders this.$nextTick(() => { const input = document.getElementById('quickSwitcherInput'); if (input) input.focus(); }); }, closeQuickSwitcher() { this.showQuickSwitcher = false; this.quickSwitcherQuery = ''; this.quickSwitcherIndex = 0; }, // Filter notes for quick switcher based on query filterQuickSwitcher(query) { // Only include actual notes, not images const notes = (this.notes || []).filter(n => n.type === 'note'); if (!query || !query.trim()) { // Show recent notes when no query return notes.slice(0, 10); } const q = query.toLowerCase(); return notes .filter(n => n.name.toLowerCase().includes(q) || n.path.toLowerCase().includes(q) ) .slice(0, 10); }, // Handle keyboard navigation in quick switcher handleQuickSwitcherKeydown(e) { const results = this.quickSwitcherResults; if (e.key === 'ArrowDown') { e.preventDefault(); this.quickSwitcherIndex = Math.min(this.quickSwitcherIndex + 1, results.length - 1); this.scrollQuickSwitcherIntoView(); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.quickSwitcherIndex = Math.max(this.quickSwitcherIndex - 1, 0); this.scrollQuickSwitcherIntoView(); } else if (e.key === 'Enter') { e.preventDefault(); const note = results[this.quickSwitcherIndex]; if (note) { this.loadNote(note.path); this.closeQuickSwitcher(); } } else if (e.key === 'Escape') { e.preventDefault(); this.closeQuickSwitcher(); } }, // Scroll selected item into view in quick switcher scrollQuickSwitcherIntoView() { this.$nextTick(() => { const items = document.querySelectorAll('[data-quick-switcher-item]'); if (items[this.quickSwitcherIndex]) { items[this.quickSwitcherIndex].scrollIntoView({ block: 'nearest' }); } }); }, // Select note from quick switcher by click selectQuickSwitcherNote(note) { this.loadNote(note.path); this.closeQuickSwitcher(); }, // Close share modal and reset state after animation closeShareModal() { this.showShareModal = false; // Delay state reset until modal is fully hidden setTimeout(() => { this.showShareQR = false; this.shareInfo = null; this.shareLoading = false; }, 200); }, // Generate QR code for share URL generateQRCode(url) { if (!url || typeof qrcode === 'undefined') return ''; try { const qr = qrcode(0, 'M'); // 0 = auto version, M = medium error correction qr.addData(url); qr.make(); return qr.createDataURL(4); // 4 = module size in pixels } catch (e) { console.error('QR code generation failed:', e); return ''; } }, // Open share modal and fetch current share status async openShareModal() { if (!this.currentNote) return; // Reset state BEFORE showing modal to prevent flicker this.showShareQR = false; this.shareInfo = null; this.shareLoading = true; this.showShareModal = true; try { const notePath = this.currentNote.replace('.md', ''); const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); const response = await fetch(`/api/share/${encodedPath}`); if (response.ok) { this.shareInfo = await response.json(); } else { this.shareInfo = { shared: false }; } } catch (error) { console.error('Failed to get share status:', error); this.shareInfo = { shared: false }; } finally { this.shareLoading = false; } }, // Create a share link for the current note (with current theme) async createShareLink() { if (!this.currentNote) return; this.shareLoading = true; try { const notePath = this.currentNote.replace('.md', ''); const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); const response = await fetch(`/api/share/${encodedPath}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme: this.currentTheme || 'light' }) }); if (response.ok) { this.shareInfo = await response.json(); this.shareInfo.shared = true; // Update the shared paths set this._sharedNotePaths.add(this.currentNote); this._syncSharedNotePathsList(); } else { const error = await response.json(); this.toast(this.t('share.error_creating', { error: error.detail || 'Unknown error' }), { type: 'error' }); } } catch (error) { console.error('Failed to create share link:', error); this.toast(this.t('share.error_creating', { error: error.message }), { type: 'error' }); } finally { this.shareLoading = false; } }, // Copy share link to clipboard async copyShareLink() { if (!this.shareInfo?.url) return; try { await navigator.clipboard.writeText(this.shareInfo.url); } catch (error) { // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = this.shareInfo.url; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); } this.shareLinkCopied = true; setTimeout(() => { this.shareLinkCopied = false; }, 2000); }, // Revoke share link async revokeShareLink() { if (!this.currentNote) return; const ok = await this.confirmModalAsk({ message: this.t('share.confirm_revoke'), danger: false, confirmLabel: this.t('common.yes'), }); if (!ok) return; this.shareLoading = true; try { const notePath = this.currentNote.replace('.md', ''); const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); const response = await fetch(`/api/share/${encodedPath}`, { method: 'DELETE' }); if (response.ok) { this.shareInfo = { shared: false }; // Update the shared paths set this._sharedNotePaths.delete(this.currentNote); this._syncSharedNotePathsList(); } else { const error = await response.json(); this.toast(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }), { type: 'error' }); } } catch (error) { console.error('Failed to revoke share link:', error); this.toast(this.t('share.error_revoking', { error: error.message }), { type: 'error' }); } finally { this.shareLoading = false; } }, // Toggle Zen Mode (full immersive writing experience) async toggleZenMode() { if (!this.zenMode) { // Entering Zen Mode this.previousViewMode = this.viewMode; this.viewMode = 'edit'; this.mobileSidebarOpen = false; this.zenMode = true; // Request fullscreen try { const elem = document.documentElement; if (elem.requestFullscreen) { await elem.requestFullscreen(); } else if (elem.webkitRequestFullscreen) { await elem.webkitRequestFullscreen(); } else if (elem.msRequestFullscreen) { await elem.msRequestFullscreen(); } } catch (e) { // Fullscreen not supported or denied, continue anyway console.log('Fullscreen not available:', e); } // Focus editor after transition setTimeout(() => { const editor = document.getElementById('note-editor'); if (editor) editor.focus(); }, 300); } else { // Exiting Zen Mode this.zenMode = false; this.viewMode = this.previousViewMode; // Exit fullscreen try { if (document.exitFullscreen) { await document.exitFullscreen(); } else if (document.webkitExitFullscreen) { await document.webkitExitFullscreen(); } else if (document.msExitFullscreen) { await document.msExitFullscreen(); } } catch (e) { console.log('Exit fullscreen error:', e); } } }, // Homepage folder navigation methods goToHomepageFolder(folderPath) { this.showGraph = false; // Close graph when navigating this.selectedHomepageFolder = folderPath || ''; // Clear editor state to show landing page this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; this.currentMedia = ''; this.outline = []; this.backlinks = []; document.title = this.appName; // Invalidate cache to force recalculation this._homepageCache = { folderPath: null, notes: null, folders: null, breadcrumb: null }; window.history.pushState({ homepageFolder: folderPath || '' }, '', '/'); }, // Navigate to homepage root and clear all editor state goHome() { this.showGraph = false; // Close graph when going home this.selectedHomepageFolder = ''; this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; this.currentMedia = ''; this.outline = []; this.backlinks = []; this.mobileSidebarOpen = false; document.title = this.appName; // Clear undo/redo history this.undoHistory = []; this.redoHistory = []; this.hasPendingHistoryChanges = false; // Invalidate cache to force recalculation this._homepageCache = { folderPath: null, notes: null, folders: null, breadcrumb: null }; window.history.pushState({ homepageFolder: '' }, '', '/'); }, // Mobile files/home tab - context-aware behavior mobileFilesTabClick() { if (this.currentNote || this.currentMedia || this.showGraph) { // Viewing content β†’ go home this.goHome(); } else { // On homepage β†’ toggle files sidebar this.activePanel = 'files'; this.mobileSidebarOpen = !this.mobileSidebarOpen; } }, // ==================== GRAPH VIEW ==================== // Initialize the graph visualization async initGraph() { // Check if vis is loaded if (typeof vis === 'undefined') { console.error('vis-network library not loaded'); return; } this.graphLoaded = false; try { // Fetch graph data from API const response = await fetch('/api/graph'); if (!response.ok) throw new Error('Failed to fetch graph data'); const data = await response.json(); this.graphData = data; // Get container const container = document.getElementById('graph-overlay'); if (!container) return; // Get theme colors (force reflow to ensure CSS is applied) document.body.offsetHeight; // Force reflow const style = getComputedStyle(document.documentElement); // Helper to get CSS variable with fallback const getCssVar = (name, fallback) => { const value = style.getPropertyValue(name).trim(); return value || fallback; }; const accentPrimary = getCssVar('--accent-primary', '#7c3aed'); const accentSecondary = getCssVar('--accent-secondary', '#a78bfa'); const textPrimary = getCssVar('--text-primary', '#111827'); const textSecondary = getCssVar('--text-secondary', '#6b7280'); const bgPrimary = getCssVar('--bg-primary', '#ffffff'); const bgSecondary = getCssVar('--bg-secondary', '#f3f4f6'); const borderColor = getCssVar('--border-primary', '#e5e7eb'); // Prepare nodes with styling - all nodes same base color const nodes = new vis.DataSet(data.nodes.map(n => ({ id: n.id, label: n.label, title: n.id, // Tooltip shows full path color: { background: accentPrimary, border: accentPrimary, highlight: { background: accentPrimary, border: textPrimary // Darker border when selected }, hover: { background: accentSecondary, border: accentPrimary } }, font: { color: textPrimary, size: 12, face: 'system-ui, -apple-system, sans-serif' }, borderWidth: this.currentNote === n.id ? 4 : 2, chosen: { node: (values) => { values.size = 22; values.borderWidth = 4; values.borderColor = textPrimary; } } }))); // Prepare edges with styling based on type const edges = new vis.DataSet(data.edges.map((e, i) => ({ id: i, from: e.source, to: e.target, color: { color: e.type === 'wikilink' ? accentPrimary : borderColor, highlight: accentPrimary, hover: accentSecondary, opacity: 0.8 }, width: e.type === 'wikilink' ? 2 : 1, smooth: { type: 'continuous', roundness: 0.5 }, chosen: { edge: (values) => { values.width = 3; values.color = accentPrimary; } } }))); // Network options const options = { nodes: { shape: 'dot', size: 16, borderWidth: 2, shadow: { enabled: true, color: 'rgba(0,0,0,0.1)', size: 5, x: 2, y: 2 } }, edges: { arrows: { to: { enabled: true, scaleFactor: 0.5, type: 'arrow' } } }, physics: { enabled: true, solver: 'forceAtlas2Based', forceAtlas2Based: { gravitationalConstant: -50, centralGravity: 0.01, springLength: 100, springConstant: 0.08, damping: 0.4, avoidOverlap: 0.5 }, stabilization: { enabled: true, iterations: 200, updateInterval: 25 } }, interaction: { hover: true, tooltipDelay: 200, navigationButtons: false, // Using custom buttons instead keyboard: { enabled: true, bindToWindow: false }, zoomView: true, dragView: true }, layout: { improvedLayout: true, randomSeed: 42 } }; // Destroy existing instance if any if (this.graphInstance) { this.graphInstance.destroy(); this.graphInstance = null; } // Clear container to ensure clean state const graphCanvas = container.querySelector('canvas'); if (graphCanvas) graphCanvas.remove(); const visElements = container.querySelectorAll('.vis-network, .vis-navigation'); visElements.forEach(el => el.remove()); // Create the network this.graphInstance = new vis.Network(container, { nodes, edges }, options); // Store reference for callbacks const graphRef = this.graphInstance; const currentNoteRef = this.currentNote; // Wait for stabilization this.graphInstance.once('stabilizationIterationsDone', () => { graphRef.setOptions({ physics: { enabled: false } }); this.graphLoaded = true; // Focus and select current note if one is loaded if (currentNoteRef) { setTimeout(() => { try { if (graphRef && this.showGraph) { const nodeIds = graphRef.body.data.nodes.getIds(); if (nodeIds.includes(currentNoteRef)) { // Focus on the node graphRef.focus(currentNoteRef, { scale: 1.2, animation: { duration: 500, easingFunction: 'easeInOutQuad' } }); // Select the node to highlight it graphRef.selectNodes([currentNoteRef]); } } } catch (e) { // Ignore - graph may have been destroyed } }, 150); } }); // Click event - open note this.graphInstance.on('click', (params) => { if (params.nodes.length > 0) { const noteId = params.nodes[0]; this.loadNote(noteId); // Node is already selected by vis-network on click, no need to call selectNodes } }); // Double-click event - open note and close graph this.graphInstance.on('doubleClick', (params) => { if (params.nodes.length > 0) { const noteId = params.nodes[0]; // Close graph and load note this.showGraph = false; this.loadNote(noteId); } }); // Hover event - highlight connections this.graphInstance.on('hoverNode', (params) => { const nodeId = params.node; const connectedNodes = this.graphInstance.getConnectedNodes(nodeId); const connectedEdges = this.graphInstance.getConnectedEdges(nodeId); // Dim all nodes except hovered and connected const allNodes = nodes.getIds(); const updates = allNodes.map(id => ({ id, opacity: (id === nodeId || connectedNodes.includes(id)) ? 1 : 0.2 })); nodes.update(updates); }); this.graphInstance.on('blurNode', () => { // Reset all nodes to full opacity const allNodes = nodes.getIds(); const updates = allNodes.map(id => ({ id, opacity: 1 })); nodes.update(updates); }); // Add legend to container this.addGraphLegend(container, accentPrimary, borderColor, textSecondary); } catch (error) { console.error('Failed to initialize graph:', error); this.graphLoaded = true; // Stop loading indicator } }, // Add legend to graph container addGraphLegend(container, wikiColor, mdColor, textColor) { // Remove existing legend if any const existingLegend = container.querySelector('.graph-legend'); if (existingLegend) existingLegend.remove(); const legend = document.createElement('div'); legend.className = 'graph-legend'; legend.innerHTML = `
Wikilinks
${this.t('graph.markdown_links')}
${this.t('graph.click_hint')}
`; container.appendChild(legend); }, // Refresh graph when theme changes refreshGraph() { if (this.viewMode === 'graph' && this.graphInstance) { this.initGraph(); } } } }