diff --git a/backend/utils.py b/backend/utils.py index b1eaf39..ef71ca4 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -7,7 +7,7 @@ import re import shutil from pathlib import Path from typing import List, Dict, Optional, Tuple -from datetime import datetime +from datetime import datetime, timezone # In-memory cache for parsed tags @@ -201,7 +201,7 @@ def get_all_notes(notes_dir: str) -> List[Dict]: "name": md_file.stem, "path": str(relative_path.as_posix()), "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", - "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), "size": stat.st_size, "type": "note", "tags": tags @@ -354,8 +354,8 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict: line_count = sum(1 for _ in f) return { - "created": datetime.fromtimestamp(stat.st_ctime).isoformat(), - "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "created": datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat(), + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), "size": stat.st_size, "lines": line_count } @@ -477,7 +477,7 @@ def get_all_images(notes_dir: str) -> List[Dict]: "name": image_file.name, "path": str(relative_path.as_posix()), "folder": str(relative_path.parent.as_posix()), - "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), "size": stat.st_size, "type": "image" }) @@ -667,7 +667,7 @@ def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]: "name": md_file.stem, "path": str(relative_path.as_posix()), "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", - "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), "size": stat.st_size, "tags": tags }) @@ -712,7 +712,7 @@ def get_templates(notes_dir: str) -> List[Dict]: templates.append({ "name": template_file.stem, "path": str(template_file.relative_to(notes_dir).as_posix()), - "modified": datetime.fromtimestamp(stat.st_mtime).isoformat() + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() }) except Exception as e: print(f"Error reading template {template_file}: {e}") diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index a1ff48f..d69d8d9 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -50,6 +50,8 @@ - **Shareable links** - Bookmark or share direct links to notes with highlighted terms - **Refresh safe** - Page reload keeps you on the same note with search context - **Copy link button** - One-click copy of note URL to clipboard +- **Last edited indicator** - Shows relative time since last edit (e.g., "Edited 2h ago") +- **Favorites** - Star notes for quick access; displayed at top of sidebar ## 🎨 Customization @@ -265,7 +267,9 @@ date: {{date}} | `Ctrl+Alt+N` | `Cmd+Option+N` | New note | | `Ctrl+Alt+F` | `Cmd+Option+F` | New folder | | `Ctrl+Z` | `Cmd+Z` | Undo | -| `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo | +| `Ctrl+Y` | `Cmd+Y` | Redo | +| `Ctrl+Shift+Z` | `Cmd+Shift+Z` | Toggle Zen Mode | +| `Esc` | `Esc` | Exit Zen Mode | | `F3` | `F3` | Next search match | | `Shift+F3` | `Shift+F3` | Previous search match | @@ -278,6 +282,18 @@ date: {{date}} | `Ctrl+K` | `Cmd+K` | Insert link | `[text](url)` | | `Ctrl+Alt+T` | `Cmd+Option+T` | Insert table | 3x3 table placeholder | +## 🧘 Zen Mode + +Full immersive distraction-free writing experience: + +- **Full screen** - Uses browser Fullscreen API for true immersion +- **Hidden UI** - Sidebar, toolbar, and stats bar disappear +- **Centered editor** - Comfortable width for optimal reading +- **Larger text** - 18px font size with relaxed line spacing +- **Quick access** - Button in toolbar or `Ctrl+Shift+Z` shortcut +- **Easy exit** - Press `Esc`, click exit button, or use shortcut again +- **State preserved** - Returns to your previous view mode on exit + ## 🚀 Performance - **Instant loading** - No lag, no loading spinners diff --git a/frontend/app.js b/frontend/app.js index 3b7a0a4..35a83c0 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -56,8 +56,29 @@ function noteApp() { 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 + }, + + // Preview rendering debounce + _previewDebounceTimeout: null, + _lastRenderedContent: '', + _cachedRenderedHTML: '', + _mathDebounceTimeout: null, + _mermaidDebounceTimeout: null, + // Theme state currentTheme: 'light', availableThemes: [], @@ -306,6 +327,8 @@ function noteApp() { this.loadEditorWidth(); this.loadViewMode(); this.loadTagsExpanded(); + this.loadFavorites(); + this.loadFavoritesExpanded(); this.loadSyntaxHighlightSetting(); // Parse URL and load specific note if provided @@ -396,6 +419,11 @@ function noteApp() { 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; @@ -424,8 +452,8 @@ function noteApp() { this.undo(); } - // Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z for redo - if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { + // Ctrl/Cmd + Y for redo (Ctrl+Shift+Z now opens Zen mode) + if ((e.ctrlKey || e.metaKey) && e.key === 'y') { e.preventDefault(); this.redo(); } @@ -468,6 +496,18 @@ function noteApp() { e.preventDefault(); this.insertTable(); } + + // Ctrl/Cmd + Shift + Z for Zen mode (only when note is open) + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'Z') { + e.preventDefault(); + this.toggleZenMode(); + } + } + + // Escape to exit Zen mode (works anywhere) + if (e.key === 'Escape' && this.zenMode) { + e.preventDefault(); + this.toggleZenMode(); } }); } @@ -482,6 +522,15 @@ function noteApp() { } }); } + + // 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; + } + }); }, // Load app configuration @@ -707,6 +756,7 @@ function noteApp() { 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) { @@ -714,6 +764,56 @@ function noteApp() { } }, + // 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(); + + for (const note of this.notes) { + const path = note.path; + const pathLower = path.toLowerCase(); + const name = note.name; + const nameLower = name.toLowerCase(); + 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') + ); + }, + // Load all tags async loadTags() { try { @@ -917,6 +1017,111 @@ function noteApp() { return note && note.tags ? note.tags : []; }, + // ==================== FAVORITES ==================== + + // Load favorites from localStorage + loadFavorites() { + try { + const stored = localStorage.getItem('noteFavorites'); + if (stored) { + this.favorites = JSON.parse(stored); + this.favoritesSet = new Set(this.favorites); + } + } catch (e) { + this.favorites = []; + this.favoritesSet = new Set(); + } + }, + + // 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.favoritesSet.delete(path); + this.favorites = this.favorites.filter(f => f !== path); + } else { + // Add to favorites + this.favoritesSet.add(path); + this.favorites.push(path); + } + + this.saveFavorites(); + }, + + // Get favorite notes with full details (for display) + get favoriteNotes() { + return this.favorites + .map(path => { + const note = this.notes.find(n => n.path === path); + if (!note) return null; + return { + path: note.path, + name: note.path.split('/').pop().replace('.md', ''), + folder: note.folder || '' + }; + }) + .filter(Boolean); // Remove nulls (deleted notes) + }, + + loadFavoritesExpanded() { + try { + const saved = localStorage.getItem('favoritesExpanded'); + if (saved !== null) { + this.favoritesExpanded = saved === 'true'; + } + } catch (e) { + console.error('Error loading favorites expanded state:', e); + } + }, + + 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 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + // For older dates, show the date + return modified.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + }, + // Parse tags from markdown content (matches backend logic) parseTagsFromContent(content) { if (!content || !content.trim().startsWith('---')) { @@ -1839,6 +2044,8 @@ function noteApp() { const data = await response.json(); this.currentNote = notePath; + this._lastRenderedContent = ''; // Clear render cache for new note + this._cachedRenderedHTML = ''; this.noteContent = data.content; this.currentNoteName = notePath.split('/').pop().replace('.md', ''); this.currentImage = ''; // Clear image viewer when loading a note @@ -2707,6 +2914,8 @@ function noteApp() { this.currentNote = ''; this.noteContent = ''; this.currentNoteName = ''; + this._lastRenderedContent = ''; // Clear render cache + this._cachedRenderedHTML = ''; // Redirect to root window.history.replaceState({}, '', '/'); } @@ -2855,6 +3064,11 @@ function noteApp() { 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._cachedRenderedHTML) { + return this._cachedRenderedHTML; + } + // Strip YAML frontmatter from content before rendering let contentToRender = this.noteContent; if (contentToRender.trim().startsWith('---')) { @@ -2877,37 +3091,18 @@ function noteApp() { // Convert Obsidian-style wikilinks: [[note]] or [[note|display text]] // Must be done before marked.parse() to avoid conflicts with markdown syntax - const notes = this.notes; // Reference for closure + const self = this; // Reference for closure contentToRender = contentToRender.replace( /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, target, displayText) => { const linkTarget = target.trim(); const linkText = displayText ? displayText.trim() : linkTarget; - const linkTargetLower = linkTarget.toLowerCase(); - // Check if note exists (by path or by name, case-insensitive) - const noteExists = notes.some(n => { - const pathLower = n.path.toLowerCase(); - const nameLower = n.name.toLowerCase(); - return ( - // Exact path match - n.path === linkTarget || - n.path === linkTarget + '.md' || - // Case-insensitive path match - pathLower === linkTargetLower || - pathLower === linkTargetLower + '.md' || - // Name match (with or without .md) - n.name === linkTarget || - n.name === linkTarget + '.md' || - nameLower === linkTargetLower || - nameLower === linkTargetLower + '.md' || - // Match by filename at end of path - n.path.endsWith('/' + linkTarget) || - n.path.endsWith('/' + linkTarget + '.md') || - pathLower.endsWith('/' + linkTargetLower) || - pathLower.endsWith('/' + linkTargetLower + '.md') - ); - }); + // 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'); @@ -2985,11 +3180,13 @@ function noteApp() { html = tempDiv.innerHTML; - // Trigger MathJax rendering after DOM updates - this.typesetMath(); + // Debounced MathJax rendering (avoid re-running on every keystroke) + if (this._mathDebounceTimeout) clearTimeout(this._mathDebounceTimeout); + this._mathDebounceTimeout = setTimeout(() => this.typesetMath(), 300); - // Render Mermaid diagrams - this.renderMermaid(); + // 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(() => { @@ -3013,6 +3210,10 @@ function noteApp() { } }, 0); + // Cache the result for performance + this._lastRenderedContent = this.noteContent; + this._cachedRenderedHTML = html; + return html; }, @@ -4043,6 +4244,55 @@ function noteApp() { }, 1500); }, + // 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 diff --git a/frontend/index.html b/frontend/index.html index 663b764..8cc72ff 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -470,6 +470,67 @@ background-color: var(--bg-primary); z-index: 0; } + + /* Zen Mode Styles */ + .zen-mode-active { + background-color: var(--bg-primary); + } + .zen-mode-active .zen-hide { + display: none !important; + } + .zen-mode-active .zen-editor-container { + width: 100%; + margin: 0; + padding: 2rem 4rem; + height: 100vh; + } + .zen-mode-active .zen-editor-container .editor-wrapper { + background-color: var(--bg-primary); + } + .zen-mode-active .zen-editor-container .editor-textarea { + font-size: 18px; + line-height: 1.8; + } + .zen-mode-active .zen-editor-container .syntax-overlay { + font-size: 18px; + line-height: 1.8; + } + .zen-exit-button { + position: fixed; + bottom: 2rem; + right: 2rem; + padding: 0.75rem 1.25rem; + border-radius: 9999px; + background-color: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-primary); + cursor: pointer; + opacity: 0; + transition: opacity 0.3s ease, transform 0.2s ease; + z-index: 9999; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + .zen-exit-button:hover { + opacity: 1 !important; + background-color: var(--bg-secondary); + transform: scale(1.05); + } + .zen-mode-active:hover .zen-exit-button { + opacity: 0.6; + } + + /* Zen mode fade transition */ + .zen-fade-enter { + animation: zenFadeIn 0.3s ease-out; + } + @keyframes zenFadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + /* Syntax highlight colors using theme variables */ .syntax-overlay .md-heading { color: var(--accent-primary); font-weight: 600; } .syntax-overlay .md-bold { color: var(--text-primary); font-weight: 700; } @@ -1000,7 +1061,23 @@ } - + + + +
@@ -1138,6 +1215,62 @@
+ + +
+
+
+ + + + Favorites +
+ + + +
+
+ +
+
+
Folders & Notes @@ -1741,9 +1874,9 @@