Merge pull request #64 from gamosoft/features/graph

Features/graph
This commit is contained in:
Gamosoft 2025-12-05 15:31:15 +01:00 committed by GitHub
commit ebc070561f
7 changed files with 531 additions and 81 deletions

View File

@ -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

View File

@ -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({

View File

@ -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:

View File

@ -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",

View File

@ -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

View File

@ -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 = '';
@ -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();
}
} }
} }
} }

View File

@ -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,11 +1152,12 @@
<!-- 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 -->
<div class="flex gap-2 mb-2">
<select <select
x-model="currentTheme" x-model="currentTheme"
@change="setTheme(currentTheme)" @change="setTheme(currentTheme)"
class="w-full px-2 py-1.5 text-xs rounded mb-2 focus:outline-none focus:ring-2 focus:ring-opacity-50" class="flex-1 px-2 py-1.5 text-xs rounded focus:outline-none focus:ring-2 focus:ring-opacity-50"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); cursor: pointer;" style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); cursor: pointer;"
title="Select theme" title="Select theme"
> >
@ -1109,6 +1165,15 @@
<option :value="theme.id" x-text="theme.name"></option> <option :value="theme.id" x-text="theme.name"></option>
</template> </template>
</select> </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;"