commit
ebc070561f
|
|
@ -43,6 +43,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
|
||||||
- 📂 **Simple Storage** - Plain markdown files in folders
|
- 📂 **Simple Storage** - Plain markdown files in folders
|
||||||
- 🧮 **Math Support** - LaTeX/MathJax for beautiful equations
|
- 🧮 **Math Support** - LaTeX/MathJax for beautiful equations
|
||||||
- 📄 **HTML Export** - Share notes as standalone HTML files
|
- 📄 **HTML Export** - Share notes as standalone HTML files
|
||||||
|
- 🕸️ **Graph View** - Interactive visualization of connected notes
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1006,9 +1006,10 @@ async def search(q: str):
|
||||||
|
|
||||||
@api_router.get("/graph")
|
@api_router.get("/graph")
|
||||||
async def get_graph():
|
async def get_graph():
|
||||||
"""Get graph data for note visualization with wikilink detection"""
|
"""Get graph data for note visualization with wikilink and markdown link detection"""
|
||||||
try:
|
try:
|
||||||
import re
|
import re
|
||||||
|
import urllib.parse
|
||||||
notes = get_all_notes(config['storage']['notes_dir'])
|
notes = get_all_notes(config['storage']['notes_dir'])
|
||||||
nodes = []
|
nodes = []
|
||||||
edges = []
|
edges = []
|
||||||
|
|
@ -1044,8 +1045,9 @@ async def get_graph():
|
||||||
# Find wikilinks: [[target]] or [[target|display]]
|
# Find wikilinks: [[target]] or [[target|display]]
|
||||||
wikilinks = re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content)
|
wikilinks = re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content)
|
||||||
|
|
||||||
# Find standard markdown internal links: [text](path.md)
|
# Find standard markdown internal links: [text](path) - any local path (not http/https)
|
||||||
markdown_links = re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content)
|
# Match links that don't start with http://, https://, mailto:, #, etc.
|
||||||
|
markdown_links = re.findall(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', content)
|
||||||
|
|
||||||
# Process wikilinks
|
# Process wikilinks
|
||||||
for target in wikilinks:
|
for target in wikilinks:
|
||||||
|
|
@ -1079,12 +1081,50 @@ async def get_graph():
|
||||||
|
|
||||||
# Process markdown links
|
# Process markdown links
|
||||||
for _, link_path in markdown_links:
|
for _, link_path in markdown_links:
|
||||||
# Try exact match first, then case-insensitive
|
# Skip anchor-only links and external protocols
|
||||||
|
if not link_path or link_path.startswith('#'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remove anchor part if present (e.g., "note.md#section" -> "note.md")
|
||||||
|
link_path = link_path.split('#')[0]
|
||||||
|
if not link_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normalize path: remove ./ prefix, handle URL encoding
|
||||||
|
link_path = urllib.parse.unquote(link_path)
|
||||||
|
if link_path.startswith('./'):
|
||||||
|
link_path = link_path[2:]
|
||||||
|
|
||||||
|
# Add .md extension if not present and doesn't have other extension
|
||||||
|
link_path_with_md = link_path if link_path.endswith('.md') else link_path + '.md'
|
||||||
|
link_path_lower = link_path.lower()
|
||||||
|
link_path_with_md_lower = link_path_with_md.lower()
|
||||||
|
|
||||||
|
# Try to match target to an existing note
|
||||||
target_path = None
|
target_path = None
|
||||||
|
|
||||||
|
# 1. Exact path match (with or without .md)
|
||||||
if link_path in note_paths:
|
if link_path in note_paths:
|
||||||
target_path = link_path
|
target_path = link_path if link_path.endswith('.md') else link_path + '.md'
|
||||||
elif link_path.lower() in note_paths_lower:
|
elif link_path_with_md in note_paths:
|
||||||
target_path = note_paths_lower[link_path.lower()]
|
target_path = link_path_with_md
|
||||||
|
# 2. Case-insensitive path match
|
||||||
|
elif link_path_lower in note_paths_lower:
|
||||||
|
target_path = note_paths_lower[link_path_lower]
|
||||||
|
elif link_path_with_md_lower in note_paths_lower:
|
||||||
|
target_path = note_paths_lower[link_path_with_md_lower]
|
||||||
|
# 3. Try matching by filename only (for relative links)
|
||||||
|
else:
|
||||||
|
# Extract just the filename
|
||||||
|
filename = link_path.split('/')[-1]
|
||||||
|
filename_lower = filename.lower()
|
||||||
|
filename_with_md = filename if filename.endswith('.md') else filename + '.md'
|
||||||
|
filename_with_md_lower = filename_with_md.lower()
|
||||||
|
|
||||||
|
if filename_lower in note_names:
|
||||||
|
target_path = note_names[filename_lower]
|
||||||
|
elif filename_with_md_lower in note_names:
|
||||||
|
target_path = note_names[filename_with_md_lower]
|
||||||
|
|
||||||
if target_path and target_path != note['path']:
|
if target_path and target_path != note['path']:
|
||||||
edges.append({
|
edges.append({
|
||||||
|
|
|
||||||
|
|
@ -273,8 +273,9 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Full-text search through note contents only.
|
Full-text search through note contents only.
|
||||||
Does NOT search in file names, folder names, or paths - only note content.
|
Does NOT search in file names, folder names, or paths - only note content.
|
||||||
Uses character-based context extraction for better snippet quality.
|
Uses character-based context extraction with highlighted matches.
|
||||||
"""
|
"""
|
||||||
|
from html import escape
|
||||||
results = []
|
results = []
|
||||||
notes_path = Path(notes_dir)
|
notes_path = Path(notes_dir)
|
||||||
|
|
||||||
|
|
@ -292,16 +293,19 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]:
|
||||||
for match in matches[:3]: # Limit to 3 matches per file
|
for match in matches[:3]: # Limit to 3 matches per file
|
||||||
start_index = match.start()
|
start_index = match.start()
|
||||||
end_index = match.end()
|
end_index = match.end()
|
||||||
|
matched_text = match.group() # Preserve original case
|
||||||
|
|
||||||
# Create slice window: ±30 characters around match
|
# Create slice window: ±15 characters around match
|
||||||
context_start = max(0, start_index - 30)
|
context_start = max(0, start_index - 15)
|
||||||
context_end = min(len(content), end_index + 30)
|
context_end = min(len(content), end_index + 15)
|
||||||
|
|
||||||
# Extract snippet
|
# Extract and clean parts (newlines → spaces)
|
||||||
snippet = content[context_start:context_end]
|
before = escape(content[context_start:start_index].replace('\n', ' '))
|
||||||
|
after = escape(content[end_index:context_end].replace('\n', ' '))
|
||||||
|
matched_clean = escape(matched_text.replace('\n', ' '))
|
||||||
|
|
||||||
# Replace all newlines with spaces to ensure UI doesn't break
|
# Build snippet with <mark> highlight (styled via CSS)
|
||||||
snippet = snippet.replace('\n', ' ')
|
snippet = f'{before}<mark class="search-highlight">{matched_clean}</mark>{after}'
|
||||||
|
|
||||||
# Add ellipsis if truncated at start
|
# Add ellipsis if truncated at start
|
||||||
if context_start > 0:
|
if context_start > 0:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<title>NoteDiscovery - Your Self-Hosted Knowledge Base</title>
|
<title>NoteDiscovery - Your Self-Hosted Knowledge Base</title>
|
||||||
<meta name="title" content="NoteDiscovery - Your Self-Hosted Knowledge Base">
|
<meta name="title" content="NoteDiscovery - Your Self-Hosted Knowledge Base">
|
||||||
<meta name="description" content="A lightweight, privacy-focused Markdown note-taking application with wikilinks, LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source.">
|
<meta name="description" content="A lightweight, privacy-focused Markdown note-taking application with wikilinks, graph view, LaTeX math, Mermaid diagrams, tags, templates, and code highlighting. Self-hosted, free, and open source.">
|
||||||
<meta name="keywords" content="note taking, markdown editor, self-hosted, knowledge base, open source, privacy, notes app, docker, second brain, obsidian alternative, notion, evernote, onenote, latex, mermaid, diagrams, math equations, templates, tags, yaml frontmatter">
|
<meta name="keywords" content="note taking, markdown editor, self-hosted, knowledge base, open source, privacy, notes app, docker, second brain, obsidian alternative, notion, evernote, onenote, latex, mermaid, diagrams, math equations, templates, tags, yaml frontmatter">
|
||||||
<meta name="author" content="Gamosoft">
|
<meta name="author" content="Gamosoft">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
|
|
@ -586,6 +586,12 @@
|
||||||
<p>Link notes with [[double brackets]] Obsidian-style. Broken links shown dimmed.</p>
|
<p>Link notes with [[double brackets]] Obsidian-style. Broken links shown dimmed.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature-icon">🕸️</div>
|
||||||
|
<h3>Graph View</h3>
|
||||||
|
<p>Interactive visualization of connected notes. Drag to explore relationships.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<div class="feature-icon">📂</div>
|
<div class="feature-icon">📂</div>
|
||||||
<h3>Simple Storage</h3>
|
<h3>Simple Storage</h3>
|
||||||
|
|
@ -664,6 +670,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="benefit-item">
|
||||||
|
<span class="emoji">🕸️</span>
|
||||||
|
<div class="text">
|
||||||
|
<strong>Graph View</strong>
|
||||||
|
Visualize note connections
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="benefit-item">
|
<div class="benefit-item">
|
||||||
<span class="emoji">🖼️</span>
|
<span class="emoji">🖼️</span>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
|
|
@ -759,7 +773,7 @@
|
||||||
"url": "https://www.notediscovery.com",
|
"url": "https://www.notediscovery.com",
|
||||||
"applicationCategory": "ProductivityApplication",
|
"applicationCategory": "ProductivityApplication",
|
||||||
"operatingSystem": "Linux, Windows, macOS",
|
"operatingSystem": "Linux, Windows, macOS",
|
||||||
"featureList": "Markdown editor, Wikilinks, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain",
|
"featureList": "Markdown editor, Wikilinks, Graph view, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain",
|
||||||
"offers": {
|
"offers": {
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
"price": "0",
|
"price": "0",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@
|
||||||
|
|
||||||
## 🔗 Linking & Discovery
|
## 🔗 Linking & Discovery
|
||||||
|
|
||||||
|
### Graph View
|
||||||
|
- **Interactive graph** - Visualize all your notes and their connections
|
||||||
|
- **Navigate with mouse** - Drag to pan, scroll to zoom, double-click nodes to open notes
|
||||||
|
- **Multiple link types** - See wikilinks and markdown links distinguished by color
|
||||||
|
- **Theme-aware** - Graph colors adapt to your current theme
|
||||||
|
|
||||||
### Internal Links
|
### Internal Links
|
||||||
- **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking
|
- **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking
|
||||||
- **Wikilinks with display text** - `[[Note Name|Click here]]` to customize link text
|
- **Wikilinks with display text** - `[[Note Name|Click here]]` to customize link text
|
||||||
|
|
|
||||||
347
frontend/app.js
347
frontend/app.js
|
|
@ -42,6 +42,12 @@ function noteApp() {
|
||||||
noteContent: '',
|
noteContent: '',
|
||||||
viewMode: 'split', // 'edit', 'split', 'preview'
|
viewMode: 'split', // 'edit', 'split', 'preview'
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
|
|
||||||
|
// Graph state (separate overlay, doesn't affect viewMode)
|
||||||
|
showGraph: false,
|
||||||
|
graphInstance: null,
|
||||||
|
graphLoaded: false,
|
||||||
|
graphData: null,
|
||||||
searchResults: [],
|
searchResults: [],
|
||||||
currentSearchHighlight: '', // Track current highlighted search term
|
currentSearchHighlight: '', // Track current highlighted search term
|
||||||
currentMatchIndex: 0, // Current match being viewed
|
currentMatchIndex: 0, // Current match being viewed
|
||||||
|
|
@ -549,6 +555,11 @@ function noteApp() {
|
||||||
this.renderMermaid();
|
this.renderMermaid();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh graph if visible (longer delay to ensure CSS is applied)
|
||||||
|
if (this.showGraph) {
|
||||||
|
setTimeout(() => this.initGraph(), 300);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load theme:', error);
|
console.error('Failed to load theme:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -971,7 +982,7 @@ function noteApp() {
|
||||||
@dragenter.prevent="dragOverFolder = '${folder.path.replace(/'/g, "\\'")}'"
|
@dragenter.prevent="dragOverFolder = '${folder.path.replace(/'/g, "\\'")}'"
|
||||||
@dragleave="dragOverFolder = null"
|
@dragleave="dragOverFolder = null"
|
||||||
@drop.stop="onFolderDrop('${folder.path.replace(/'/g, "\\'")}' )"
|
@drop.stop="onFolderDrop('${folder.path.replace(/'/g, "\\'")}' )"
|
||||||
class="folder-item px-3 py-3 mb-1 text-sm rounded transition-all relative"
|
class="folder-item px-2 py-1 text-sm relative"
|
||||||
style="color: var(--text-primary); cursor: pointer;"
|
style="color: var(--text-primary); cursor: pointer;"
|
||||||
:class="{
|
:class="{
|
||||||
'border-2 border-dashed bg-accent-light': (draggedNote || draggedFolder) && dragOverFolder === '${folder.path.replace(/'/g, "\\'")}',
|
'border-2 border-dashed bg-accent-light': (draggedNote || draggedFolder) && dragOverFolder === '${folder.path.replace(/'/g, "\\'")}',
|
||||||
|
|
@ -1029,7 +1040,7 @@ function noteApp() {
|
||||||
|
|
||||||
// If expanded, render folder contents (child folders + notes)
|
// If expanded, render folder contents (child folders + notes)
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
html += `<div class="folder-contents" style="padding-left: 12px;">`;
|
html += `<div class="folder-contents" style="padding-left: 10px;">`;
|
||||||
|
|
||||||
// First, render child folders (if any)
|
// First, render child folders (if any)
|
||||||
if (folder.children && Object.keys(folder.children).length > 0) {
|
if (folder.children && Object.keys(folder.children).length > 0) {
|
||||||
|
|
@ -1055,9 +1066,7 @@ function noteApp() {
|
||||||
const icon = isImage ? '🖼️' : '';
|
const icon = isImage ? '🖼️' : '';
|
||||||
|
|
||||||
// Click handler
|
// Click handler
|
||||||
const clickHandler = isImage
|
const clickHandler = `openItem('${note.path.replace(/'/g, "\\'")}', '${note.type}')`;
|
||||||
? `viewImage('${note.path.replace(/'/g, "\\'")}')`
|
|
||||||
: `loadNote('${note.path.replace(/'/g, "\\'")}')`;
|
|
||||||
|
|
||||||
// Delete handler
|
// Delete handler
|
||||||
const deleteHandler = isImage
|
const deleteHandler = isImage
|
||||||
|
|
@ -1071,7 +1080,7 @@ function noteApp() {
|
||||||
@dragstart="onNoteDragStart('${note.path.replace(/'/g, "\\'")}', $event)"
|
@dragstart="onNoteDragStart('${note.path.replace(/'/g, "\\'")}', $event)"
|
||||||
@dragend="onNoteDragEnd()"
|
@dragend="onNoteDragEnd()"
|
||||||
@click="${clickHandler}"
|
@click="${clickHandler}"
|
||||||
class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent"
|
class="note-item px-2 py-1 text-sm relative border-2 border-transparent"
|
||||||
style="${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isImage ? 'opacity: 0.85;' : ''} cursor: pointer;"
|
style="${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isImage ? 'opacity: 0.85;' : ''} cursor: pointer;"
|
||||||
@mouseover="if('${note.path}' !== currentNote && '${note.path}' !== currentImage) $el.style.backgroundColor='var(--bg-hover)'"
|
@mouseover="if('${note.path}' !== currentNote && '${note.path}' !== currentImage) $el.style.backgroundColor='var(--bg-hover)'"
|
||||||
@mouseout="if('${note.path}' !== currentNote && '${note.path}' !== currentImage) $el.style.backgroundColor='transparent'"
|
@mouseout="if('${note.path}' !== currentNote && '${note.path}' !== currentImage) $el.style.backgroundColor='transparent'"
|
||||||
|
|
@ -1160,7 +1169,7 @@ function noteApp() {
|
||||||
const sidebar = document.querySelector('.flex-1.overflow-y-auto.custom-scrollbar');
|
const sidebar = document.querySelector('.flex-1.overflow-y-auto.custom-scrollbar');
|
||||||
if (!sidebar) return;
|
if (!sidebar) return;
|
||||||
|
|
||||||
const noteElements = sidebar.querySelectorAll('[class*="px-3 py-2 mb-1"]');
|
const noteElements = sidebar.querySelectorAll('.note-item');
|
||||||
let targetElement = null;
|
let targetElement = null;
|
||||||
const noteName = notePath.split('/').pop().replace('.md', '');
|
const noteName = notePath.split('/').pop().replace('.md', '');
|
||||||
|
|
||||||
|
|
@ -1431,8 +1440,19 @@ function noteApp() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Open a note or image (unified handler for sidebar/homepage clicks)
|
||||||
|
openItem(path, type = 'note', searchHighlight = '') {
|
||||||
|
this.showGraph = false;
|
||||||
|
if (type === 'image' || path.match(/\.(png|jpg|jpeg|gif|webp)$/i)) {
|
||||||
|
this.viewImage(path);
|
||||||
|
} else {
|
||||||
|
this.loadNote(path, true, searchHighlight);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// View an image in the main pane
|
// View an image in the main pane
|
||||||
viewImage(imagePath, updateHistory = true) {
|
viewImage(imagePath, updateHistory = true) {
|
||||||
|
this.showGraph = false; // Ensure graph is closed
|
||||||
this.currentNote = '';
|
this.currentNote = '';
|
||||||
this.currentNoteName = '';
|
this.currentNoteName = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
|
|
@ -1526,8 +1546,8 @@ function noteApp() {
|
||||||
);
|
);
|
||||||
if (noteByPathCI) {
|
if (noteByPathCI) {
|
||||||
this.loadNote(noteByPathCI.path);
|
this.loadNote(noteByPathCI.path);
|
||||||
} else {
|
} else {
|
||||||
alert(`Note not found: ${notePath}`);
|
alert(`Note not found: ${notePath}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1895,18 +1915,10 @@ function noteApp() {
|
||||||
mark.className = 'search-highlight';
|
mark.className = 'search-highlight';
|
||||||
mark.setAttribute('data-match-index', matchIndex);
|
mark.setAttribute('data-match-index', matchIndex);
|
||||||
mark.textContent = text.substring(index, index + searchTerm.length);
|
mark.textContent = text.substring(index, index + searchTerm.length);
|
||||||
mark.style.padding = '2px 4px';
|
|
||||||
mark.style.borderRadius = '3px';
|
|
||||||
mark.style.transition = 'all 0.2s';
|
|
||||||
|
|
||||||
// Style first match as active, others as inactive
|
// First match is active (styled via CSS)
|
||||||
if (matchIndex === 0) {
|
if (matchIndex === 0) {
|
||||||
mark.style.backgroundColor = 'var(--accent-primary)';
|
|
||||||
mark.style.color = 'white';
|
|
||||||
mark.classList.add('active-match');
|
mark.classList.add('active-match');
|
||||||
} else {
|
|
||||||
mark.style.backgroundColor = 'rgba(255, 193, 7, 0.4)';
|
|
||||||
mark.style.color = 'var(--text-primary)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment.appendChild(mark);
|
fragment.appendChild(mark);
|
||||||
|
|
@ -1961,17 +1973,9 @@ function noteApp() {
|
||||||
const allMatches = preview.querySelectorAll('mark.search-highlight');
|
const allMatches = preview.querySelectorAll('mark.search-highlight');
|
||||||
if (index < 0 || index >= allMatches.length) return;
|
if (index < 0 || index >= allMatches.length) return;
|
||||||
|
|
||||||
// Update styling - make current match prominent
|
// Update styling - make current match prominent (via CSS class)
|
||||||
allMatches.forEach((mark, i) => {
|
allMatches.forEach((mark, i) => {
|
||||||
if (i === index) {
|
mark.classList.toggle('active-match', i === index);
|
||||||
mark.style.backgroundColor = 'var(--accent-primary)';
|
|
||||||
mark.style.color = 'white';
|
|
||||||
mark.classList.add('active-match');
|
|
||||||
} else {
|
|
||||||
mark.style.backgroundColor = 'rgba(255, 193, 7, 0.4)';
|
|
||||||
mark.style.color = 'var(--text-primary)';
|
|
||||||
mark.classList.remove('active-match');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scroll to the match
|
// Scroll to the match
|
||||||
|
|
@ -3753,6 +3757,7 @@ function noteApp() {
|
||||||
|
|
||||||
// Homepage folder navigation methods
|
// Homepage folder navigation methods
|
||||||
goToHomepageFolder(folderPath) {
|
goToHomepageFolder(folderPath) {
|
||||||
|
this.showGraph = false; // Close graph when navigating
|
||||||
this.selectedHomepageFolder = folderPath || '';
|
this.selectedHomepageFolder = folderPath || '';
|
||||||
|
|
||||||
// Clear editor state to show landing page
|
// Clear editor state to show landing page
|
||||||
|
|
@ -3774,6 +3779,7 @@ function noteApp() {
|
||||||
|
|
||||||
// Navigate to homepage root and clear all editor state
|
// Navigate to homepage root and clear all editor state
|
||||||
goHome() {
|
goHome() {
|
||||||
|
this.showGraph = false; // Close graph when going home
|
||||||
this.selectedHomepageFolder = '';
|
this.selectedHomepageFolder = '';
|
||||||
this.currentNote = '';
|
this.currentNote = '';
|
||||||
this.currentNoteName = '';
|
this.currentNoteName = '';
|
||||||
|
|
@ -3790,6 +3796,291 @@ function noteApp() {
|
||||||
};
|
};
|
||||||
|
|
||||||
window.history.pushState({ homepageFolder: '' }, '', '/');
|
window.history.pushState({ homepageFolder: '' }, '', '/');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 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 = `
|
||||||
|
<div class="graph-legend-item">
|
||||||
|
<span class="graph-legend-dot" style="background: ${wikiColor};"></span>
|
||||||
|
<span style="color: ${textColor};">Wikilinks</span>
|
||||||
|
</div>
|
||||||
|
<div class="graph-legend-item">
|
||||||
|
<span class="graph-legend-dot" style="background: ${mdColor};"></span>
|
||||||
|
<span style="color: ${textColor};">Markdown links</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 8px; font-size: 10px; color: ${textColor}; opacity: 0.7;">
|
||||||
|
Click: select • Double-click: open
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(legend);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh graph when theme changes
|
||||||
|
refreshGraph() {
|
||||||
|
if (this.viewMode === 'graph' && this.graphInstance) {
|
||||||
|
this.initGraph();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@
|
||||||
window.mermaid = mermaid;
|
window.mermaid = mermaid;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- vis-network for graph visualization (v9.1.9) -->
|
||||||
|
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||||
|
|
||||||
<!-- Theme styles will be loaded dynamically -->
|
<!-- Theme styles will be loaded dynamically -->
|
||||||
<link rel="stylesheet" id="theme-stylesheet" href="">
|
<link rel="stylesheet" id="theme-stylesheet" href="">
|
||||||
|
|
||||||
|
|
@ -290,6 +293,19 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search highlight base style (used in note preview and search results) */
|
||||||
|
.search-highlight {
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: rgba(255, 193, 7, 0.4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.search-highlight.active-match {
|
||||||
|
background-color: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
/* Note Properties Panel */
|
/* Note Properties Panel */
|
||||||
.note-properties {
|
.note-properties {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
|
|
@ -308,6 +324,42 @@
|
||||||
border-bottom-style: solid;
|
border-bottom-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Graph View */
|
||||||
|
#graph-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
#graph-overlay .vis-network {
|
||||||
|
outline: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
.graph-legend {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.graph-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.graph-legend-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
/* Standard internal links (non-wikilink, e.g. [text](path)) */
|
/* Standard internal links (non-wikilink, e.g. [text](path)) */
|
||||||
.markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]):not([data-wikilink]) {
|
.markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]):not([data-wikilink]) {
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
|
|
@ -391,10 +443,10 @@
|
||||||
cursor: copy !important;
|
cursor: copy !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Folder tree styling */
|
/* Folder tree styling - compact Obsidian-like */
|
||||||
.folder-item {
|
.folder-item {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.2s ease;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-item:hover {
|
.folder-item:hover {
|
||||||
|
|
@ -406,8 +458,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-item {
|
.note-item {
|
||||||
padding-left: 1.5rem;
|
border-radius: 4px;
|
||||||
transition: all 0.2s ease;
|
}
|
||||||
|
|
||||||
|
.note-item:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drag and drop improvements */
|
/* Drag and drop improvements */
|
||||||
|
|
@ -424,7 +479,7 @@
|
||||||
/* Smooth transitions for drag states */
|
/* Smooth transitions for drag states */
|
||||||
.folder-item,
|
.folder-item,
|
||||||
.note-item {
|
.note-item {
|
||||||
transition: opacity 0.2s ease, transform 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Better drop zone visibility */
|
/* Better drop zone visibility */
|
||||||
|
|
@ -794,25 +849,25 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="flex-shrink-0 p-3 border-b" style="border-color: var(--border-primary);">
|
<div class="flex-shrink-0 px-2 py-2 border-b" style="border-color: var(--border-primary);">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
x-model="searchQuery"
|
x-model="searchQuery"
|
||||||
@input="searchNotes()"
|
@input="searchNotes()"
|
||||||
placeholder="Search notes..."
|
placeholder="Search..."
|
||||||
class="w-full px-3 py-2 pl-8 pr-8 text-sm rounded-lg focus:outline-none focus:ring-2"
|
class="w-full px-2 py-1.5 pl-7 pr-7 text-xs rounded focus:outline-none focus:ring-1"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary);"
|
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary);"
|
||||||
>
|
>
|
||||||
<!-- Search Icon -->
|
<!-- Search Icon -->
|
||||||
<svg class="absolute left-2 top-2.5 w-4 h-4" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="absolute left-2 top-2 w-3.5 h-3.5" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Clear Button -->
|
<!-- Clear Button -->
|
||||||
<button
|
<button
|
||||||
x-show="searchQuery.length > 0"
|
x-show="searchQuery.length > 0"
|
||||||
@click="searchQuery = ''; searchResults = []; currentSearchHighlight = ''; clearSearchHighlights();"
|
@click="searchQuery = ''; searchResults = []; currentSearchHighlight = ''; clearSearchHighlights();"
|
||||||
class="absolute right-2 top-2.5 w-4 h-4 flex items-center justify-center rounded-full hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
class="absolute right-1.5 top-1.5 w-4 h-4 flex items-center justify-center rounded-full hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
style="color: var(--text-tertiary);"
|
style="color: var(--text-tertiary);"
|
||||||
title="Clear search"
|
title="Clear search"
|
||||||
>
|
>
|
||||||
|
|
@ -825,7 +880,7 @@
|
||||||
<!-- Match Navigation (shown when there are matches in current note) -->
|
<!-- Match Navigation (shown when there are matches in current note) -->
|
||||||
<div
|
<div
|
||||||
x-show="totalMatches > 0"
|
x-show="totalMatches > 0"
|
||||||
class="flex items-center justify-between mt-2 px-2 py-1 rounded text-xs"
|
class="flex items-center justify-between mt-1.5 px-2 py-0.5 rounded text-xs"
|
||||||
style="background-color: var(--bg-secondary); color: var(--text-secondary);"
|
style="background-color: var(--bg-secondary); color: var(--text-secondary);"
|
||||||
>
|
>
|
||||||
<span x-text="`Match ${currentMatchIndex + 1} of ${totalMatches}`"></span>
|
<span x-text="`Match ${currentMatchIndex + 1} of ${totalMatches}`"></span>
|
||||||
|
|
@ -923,16 +978,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New Item Dropdown -->
|
<!-- New Item Dropdown -->
|
||||||
<div class="flex-shrink-0 p-3 border-b" style="border-color: var(--border-primary);">
|
<div class="flex-shrink-0 px-2 py-1.5 border-b" style="border-color: var(--border-primary);">
|
||||||
<button
|
<button
|
||||||
@click="dropdownTargetFolder = ''; toggleNewDropdown($event)"
|
@click="dropdownTargetFolder = ''; toggleNewDropdown($event)"
|
||||||
class="w-full px-4 py-2 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-2 flex items-center justify-center gap-2"
|
class="w-full px-3 py-1.5 text-xs font-medium text-white rounded focus:outline-none focus:ring-1 flex items-center justify-center gap-1.5"
|
||||||
style="background-color: var(--accent-primary);"
|
style="background-color: var(--accent-primary);"
|
||||||
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
|
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
|
||||||
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
|
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
|
||||||
>
|
>
|
||||||
<span>+ New</span>
|
<span>+ New</span>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M19 9l-7 7-7-7"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -948,15 +1003,15 @@
|
||||||
</div>
|
</div>
|
||||||
<template x-for="note in searchResults" :key="note.path">
|
<template x-for="note in searchResults" :key="note.path">
|
||||||
<div
|
<div
|
||||||
@click="loadNote(note.path, true, searchQuery)"
|
@click="openItem(note.path, note.type, searchQuery)"
|
||||||
class="px-3 py-2 mb-1 text-sm rounded cursor-pointer"
|
class="px-2 py-1.5 text-sm cursor-pointer rounded"
|
||||||
:style="currentNote === note.path ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'"
|
:style="currentNote === note.path ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'"
|
||||||
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
|
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
|
||||||
onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'"
|
onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'"
|
||||||
:title="note.path"
|
:title="note.path"
|
||||||
>
|
>
|
||||||
<div class="font-medium truncate" x-text="note.name"></div>
|
<div class="font-medium truncate" x-text="note.name"></div>
|
||||||
<div class="text-xs truncate" style="color: var(--text-tertiary);" x-text="(note.matches && note.matches.length > 0) ? note.matches[0].context : (note.folder || 'Root')"></div>
|
<div class="text-xs truncate" style="color: var(--text-tertiary);" x-html="(note.matches && note.matches.length > 0) ? note.matches[0].context : (note.folder || 'Root')"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1062,8 +1117,8 @@
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="onNoteDragStart(note.path, $event)"
|
@dragstart="onNoteDragStart(note.path, $event)"
|
||||||
@dragend="onNoteDragEnd()"
|
@dragend="onNoteDragEnd()"
|
||||||
@click="note.type === 'image' ? viewImage(note.path) : loadNote(note.path)"
|
@click="openItem(note.path, note.type)"
|
||||||
class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent"
|
class="note-item px-2 py-1 text-sm relative border-2 border-transparent"
|
||||||
:style="((note.type === 'image' ? currentImage === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type === 'image' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')"
|
:style="((note.type === 'image' ? currentImage === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type === 'image' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')"
|
||||||
@mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
|
@mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
|
||||||
@mouseout="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='transparent'"
|
@mouseout="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='transparent'"
|
||||||
|
|
@ -1097,18 +1152,28 @@
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="flex-shrink-0 p-3 border-t" style="border-color: var(--border-primary);">
|
<div class="flex-shrink-0 p-3 border-t" style="border-color: var(--border-primary);">
|
||||||
<!-- Theme Selector (compact) -->
|
<!-- Theme Selector and Graph Button -->
|
||||||
<select
|
<div class="flex gap-2 mb-2">
|
||||||
x-model="currentTheme"
|
<select
|
||||||
@change="setTheme(currentTheme)"
|
x-model="currentTheme"
|
||||||
class="w-full px-2 py-1.5 text-xs rounded mb-2 focus:outline-none focus:ring-2 focus:ring-opacity-50"
|
@change="setTheme(currentTheme)"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); cursor: pointer;"
|
class="flex-1 px-2 py-1.5 text-xs rounded focus:outline-none focus:ring-2 focus:ring-opacity-50"
|
||||||
title="Select theme"
|
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); cursor: pointer;"
|
||||||
>
|
title="Select theme"
|
||||||
<template x-for="theme in availableThemes" :key="theme.id">
|
>
|
||||||
<option :value="theme.id" x-text="theme.name"></option>
|
<template x-for="theme in availableThemes" :key="theme.id">
|
||||||
</template>
|
<option :value="theme.id" x-text="theme.name"></option>
|
||||||
</select>
|
</template>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
@click="showGraph = true; initGraph()"
|
||||||
|
class="px-2 py-1.5 text-xs rounded transition-colors"
|
||||||
|
:style="showGraph ? 'background-color: var(--accent-primary); color: white;' : 'background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary);'"
|
||||||
|
title="View note connections graph"
|
||||||
|
>
|
||||||
|
🔗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="text-xs text-center" style="color: var(--text-tertiary);">
|
<div class="text-xs text-center" style="color: var(--text-tertiary);">
|
||||||
|
|
@ -1140,7 +1205,36 @@
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<div class="flex-1 flex flex-col overflow-hidden">
|
<div class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- Graph View Overlay (works from homepage or editor) -->
|
||||||
|
<div
|
||||||
|
x-show="showGraph"
|
||||||
|
id="graph-overlay"
|
||||||
|
class="absolute inset-0"
|
||||||
|
style="background-color: var(--bg-primary); z-index: 50;"
|
||||||
|
>
|
||||||
|
<!-- Graph renders here via vis-network -->
|
||||||
|
<div x-show="!graphLoaded" class="flex items-center justify-center h-full">
|
||||||
|
<div class="text-center" style="color: var(--text-secondary);">
|
||||||
|
<svg class="animate-spin h-8 w-8 mx-auto mb-3" style="color: var(--accent-primary);" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p>Loading graph...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Close button -->
|
||||||
|
<button
|
||||||
|
@click="showGraph = false"
|
||||||
|
class="absolute top-4 left-4 px-3 py-2 text-sm font-medium rounded-lg transition-colors z-10"
|
||||||
|
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-primary);"
|
||||||
|
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
||||||
|
onmouseout="this.style.backgroundColor='var(--bg-secondary)'"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template x-if="!currentNote && !currentImage">
|
<template x-if="!currentNote && !currentImage">
|
||||||
<!-- Notes Homepage -->
|
<!-- Notes Homepage -->
|
||||||
|
|
@ -1310,7 +1404,7 @@
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<template x-for="note in homepageNotes().slice(0, HOMEPAGE_MAX_NOTES)" :key="note.path">
|
<template x-for="note in homepageNotes().slice(0, HOMEPAGE_MAX_NOTES)" :key="note.path">
|
||||||
<div
|
<div
|
||||||
@click="note.path.match(/\.(png|jpg|jpeg|gif|webp)$/i) ? (currentImage = note.path, currentNote = '') : loadNote(note.path)"
|
@click="openItem(note.path, note.type)"
|
||||||
class="p-4 rounded-lg border-2 cursor-pointer transition-all hover:shadow-lg"
|
class="p-4 rounded-lg border-2 cursor-pointer transition-all hover:shadow-lg"
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border-primary); min-height: 140px; display: flex; flex-direction: column; position: relative;"
|
style="background-color: var(--bg-secondary); border-color: var(--border-primary); min-height: 140px; display: flex; flex-direction: column; position: relative;"
|
||||||
onmouseover="this.style.borderColor='var(--accent-primary)'; this.style.transform='translateY(-2px)'; this.querySelector('.card-delete-btn').style.opacity='1';"
|
onmouseover="this.style.borderColor='var(--accent-primary)'; this.style.transform='translateY(-2px)'; this.querySelector('.card-delete-btn').style.opacity='1';"
|
||||||
|
|
@ -1476,11 +1570,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Editor/Preview -->
|
<!-- Editor/Preview/Graph -->
|
||||||
<div class="flex-1 flex relative" style="min-height: 0;">
|
<div class="flex-1 flex relative" style="min-height: 0;">
|
||||||
<!-- Editor -->
|
<!-- Editor -->
|
||||||
<div
|
<div
|
||||||
x-show="viewMode === 'edit' || viewMode === 'split'"
|
x-show="!currentImage && (viewMode === 'edit' || viewMode === 'split')"
|
||||||
class="flex flex-col"
|
class="flex flex-col"
|
||||||
style="min-height: 0;"
|
style="min-height: 0;"
|
||||||
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
|
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
|
||||||
|
|
@ -1502,7 +1596,7 @@
|
||||||
|
|
||||||
<!-- Split view resize handle -->
|
<!-- Split view resize handle -->
|
||||||
<div
|
<div
|
||||||
x-show="viewMode === 'split'"
|
x-show="!currentImage && viewMode === 'split'"
|
||||||
@mousedown="startSplitResize($event)"
|
@mousedown="startSplitResize($event)"
|
||||||
class="split-resize-handle"
|
class="split-resize-handle"
|
||||||
style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;"
|
style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue