added favorites and performance improvements

This commit is contained in:
Gamosoft 2025-12-12 18:11:29 +01:00
parent 74903b64ba
commit c1de0c84a1
3 changed files with 259 additions and 29 deletions

View File

@ -51,6 +51,7 @@
- **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

View File

@ -58,8 +58,27 @@ function noteApp() {
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: [],
@ -308,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
@ -398,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;
@ -730,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) {
@ -737,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 {
@ -940,6 +1017,88 @@ 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 '';
@ -1885,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
@ -2753,6 +2914,8 @@ function noteApp() {
this.currentNote = '';
this.noteContent = '';
this.currentNoteName = '';
this._lastRenderedContent = ''; // Clear render cache
this._cachedRenderedHTML = '';
// Redirect to root
window.history.replaceState({}, '', '/');
}
@ -2901,6 +3064,11 @@ function noteApp() {
get renderedMarkdown() {
if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>';
// 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('---')) {
@ -2923,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');
@ -3031,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(() => {
@ -3059,6 +3210,10 @@ function noteApp() {
}
}, 0);
// Cache the result for performance
this._lastRenderedContent = this.noteContent;
this._cachedRenderedHTML = html;
return html;
},

View File

@ -1215,6 +1215,62 @@
<!-- Folder Tree -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-2">
<!-- Favorites Section -->
<div x-show="favoriteNotes.length > 0" class="mb-3">
<div
@click="favoritesExpanded = !favoritesExpanded"
class="flex items-center justify-between px-2 py-1 cursor-pointer rounded hover:bg-opacity-50 transition-colors"
style="color: var(--text-tertiary);"
@mouseenter="$el.style.backgroundColor = 'var(--bg-hover)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<div class="flex items-center gap-1.5">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24" style="color: var(--warning, #eab308);">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
<span class="text-xs font-semibold uppercase tracking-wide">Favorites</span>
</div>
<svg
class="w-3 h-3 transition-transform"
:class="{'rotate-90': favoritesExpanded}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
<div x-show="favoritesExpanded" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 -translate-y-1" x-transition:enter-end="opacity-100 translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="mt-1">
<template x-for="fav in favoriteNotes" :key="fav.path">
<div
@click="loadNote(fav.path)"
class="flex items-center justify-between px-2 py-1.5 rounded cursor-pointer group"
:class="{'bg-opacity-50': currentNote === fav.path}"
:style="currentNote === fav.path ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'"
@mouseenter="if (currentNote !== fav.path) $el.style.backgroundColor = 'var(--bg-hover)'"
@mouseleave="if (currentNote !== fav.path) $el.style.backgroundColor = 'transparent'"
>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<svg class="w-3.5 h-3.5 flex-shrink-0" style="color: var(--warning, #eab308);" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
<span class="text-xs truncate" x-text="fav.name"></span>
</div>
<button
@click.stop="toggleFavorite(fav.path)"
class="opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity"
title="Remove from favorites"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</template>
</div>
</div>
<div class="text-xs px-2 py-1 mb-2" style="color: var(--text-tertiary);">
<div class="flex items-center justify-between mb-1">
<span>Folders & Notes</span>
@ -1938,6 +1994,24 @@
class="flex items-center rounded overflow-hidden"
style="background-color: var(--bg-tertiary); border: 1px solid var(--border-primary);"
>
<button
@click="toggleFavorite()"
class="p-2 transition"
:title="isFavorite(currentNote) ? 'Remove from favorites' : 'Add to favorites'"
:style="'color: ' + (isFavorite(currentNote) ? 'var(--warning, #eab308)' : 'var(--text-secondary)') + ';'"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<!-- Filled star (favorited) -->
<svg x-show="isFavorite(currentNote)" class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
<!-- Empty star (not favorited) -->
<svg x-show="!isFavorite(currentNote)" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"></path>
</svg>
</button>
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
<button
@click="exportToHTML()"
class="p-2 transition"