Merge pull request #110 from gamosoft/features/table-of-contents
Features/table of contents
This commit is contained in:
commit
1a05bf3968
|
|
@ -62,6 +62,7 @@ If this project has been useful to you, consider supporting its development, it
|
||||||
- 📄 **HTML Export** - Share notes as standalone HTML files
|
- 📄 **HTML Export** - Share notes as standalone HTML files
|
||||||
- 🕸️ **Graph View** - Interactive visualization of connected notes
|
- 🕸️ **Graph View** - Interactive visualization of connected notes
|
||||||
- ⭐ **Favorites** - Star your most-used notes for instant access
|
- ⭐ **Favorites** - Star your most-used notes for instant access
|
||||||
|
- 📑 **Outline Panel** - Navigate headings with click-to-jump TOC
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -379,16 +379,35 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
|
||||||
|
|
||||||
def sanitize_filename(filename: str) -> str:
|
def sanitize_filename(filename: str) -> str:
|
||||||
"""
|
"""
|
||||||
Sanitize a filename by removing/replacing invalid characters.
|
Sanitize a filename by removing/replacing dangerous filesystem characters.
|
||||||
Keeps only alphanumeric chars, dots, dashes, and underscores.
|
Supports Unicode characters (international text) while blocking:
|
||||||
|
- Windows forbidden: \\ / : * ? " < > |
|
||||||
|
- Control characters (0x00-0x1f)
|
||||||
|
|
||||||
|
Note: This is a safety net - the frontend validates before sending.
|
||||||
"""
|
"""
|
||||||
|
if not filename:
|
||||||
|
return filename
|
||||||
|
|
||||||
# Get the extension first
|
# Get the extension first
|
||||||
parts = filename.rsplit('.', 1)
|
parts = filename.rsplit('.', 1)
|
||||||
name = parts[0]
|
name = parts[0]
|
||||||
ext = parts[1] if len(parts) > 1 else ''
|
ext = parts[1] if len(parts) > 1 else ''
|
||||||
|
|
||||||
# Remove/replace invalid characters
|
# Remove dangerous characters (replace with underscore)
|
||||||
name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
# Blocklist approach: only remove what's truly dangerous
|
||||||
|
# Pattern: backslash, forward slash, colon, asterisk, question mark, quotes, angle brackets, pipe, control chars
|
||||||
|
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', '_', name)
|
||||||
|
|
||||||
|
# Collapse multiple underscores
|
||||||
|
name = re.sub(r'_+', '_', name)
|
||||||
|
|
||||||
|
# Strip leading/trailing underscores and spaces
|
||||||
|
name = name.strip('_ ')
|
||||||
|
|
||||||
|
# Ensure we have something left
|
||||||
|
if not name:
|
||||||
|
name = 'unnamed'
|
||||||
|
|
||||||
# Rejoin with extension
|
# Rejoin with extension
|
||||||
return f"{name}.{ext}" if ext else name
|
return f"{name}.{ext}" if ext else name
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ search:
|
||||||
authentication:
|
authentication:
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
# Set enabled to true to require login
|
# Set enabled to true to require login
|
||||||
enabled: true
|
enabled: false
|
||||||
|
|
||||||
# ⚠️ SECURITY WARNING: Change these values before exposing to the internet!
|
# ⚠️ SECURITY WARNING: Change these values before exposing to the internet!
|
||||||
# Default values below are for LOCAL TESTING ONLY
|
# Default values below are for LOCAL TESTING ONLY
|
||||||
|
|
|
||||||
|
|
@ -823,7 +823,7 @@
|
||||||
<div class="feature animate-on-scroll">
|
<div class="feature animate-on-scroll">
|
||||||
<div class="feature-icon">🔗</div>
|
<div class="feature-icon">🔗</div>
|
||||||
<h3>Wikilinks</h3>
|
<h3>Wikilinks</h3>
|
||||||
<p>Link notes with [[double brackets]] Obsidian-style. Broken links shown dimmed.</p>
|
<p>Link notes with [[double brackets]] Obsidian-style. Jump to sections with [[note#heading]] anchors.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feature animate-on-scroll">
|
<div class="feature animate-on-scroll">
|
||||||
|
|
@ -850,6 +850,12 @@
|
||||||
<p>Star your most-used notes for instant access from the sidebar.</p>
|
<p>Star your most-used notes for instant access from the sidebar.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature animate-on-scroll">
|
||||||
|
<div class="feature-icon">📑</div>
|
||||||
|
<h3>Outline Panel</h3>
|
||||||
|
<p>Navigate headings with a click-to-jump table of contents. Link to sections with anchors.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature animate-on-scroll">
|
<div class="feature animate-on-scroll">
|
||||||
<div class="feature-icon">🔌</div>
|
<div class="feature-icon">🔌</div>
|
||||||
<h3>Extensible</h3>
|
<h3>Extensible</h3>
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,31 @@
|
||||||
### 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
|
||||||
|
- **Section anchors** - `[[Note Name#heading]]` to link directly to a heading
|
||||||
|
- **Same-page anchors** - `[[#heading]]` to link within the current note
|
||||||
- **Broken link detection** - Non-existent note links shown dimmed
|
- **Broken link detection** - Non-existent note links shown dimmed
|
||||||
- **Markdown links** - `[Note Name](note.md)` standard syntax also supported
|
- **Markdown links** - `[text](note.md)` standard syntax also supported
|
||||||
|
- **Markdown section links** - `[text](note.md#heading)` for heading anchors
|
||||||
- **Drag to link** - Drag notes or images into the editor to insert links
|
- **Drag to link** - Drag notes or images into the editor to insert links
|
||||||
- **Click to navigate** - Jump between notes seamlessly
|
- **Click to navigate** - Jump between notes seamlessly
|
||||||
- **External links** - Open in new tabs automatically
|
- **External links** - Open in new tabs automatically
|
||||||
|
|
||||||
|
### Outline Panel
|
||||||
|
- **Table of Contents** - View all headings (H1-H6) in sidebar
|
||||||
|
- **Click to navigate** - Jump to any heading in edit or preview mode
|
||||||
|
- **Real-time updates** - Outline updates as you type
|
||||||
|
- **Hierarchical view** - Indentation shows heading structure
|
||||||
|
- **Heading count badge** - Quick indicator of document structure
|
||||||
|
|
||||||
|
### Section Link Syntax
|
||||||
|
To link to a heading, convert the heading text to a slug: **lowercase, spaces → dashes, remove special chars**.
|
||||||
|
|
||||||
|
| Heading | Slug | Link Example |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| `## Getting Started` | `getting-started` | `[[note#getting-started]]` |
|
||||||
|
| `### API Reference` | `api-reference` | `[API](note#api-reference)` |
|
||||||
|
| `## What's New?` | `whats-new` | `[[#whats-new]]` (same page) |
|
||||||
|
|
||||||
### Direct URLs
|
### Direct URLs
|
||||||
- **Deep linking** - Open specific notes via URL (e.g., `/folder/note`)
|
- **Deep linking** - Open specific notes via URL (e.g., `/folder/note`)
|
||||||
- **Search highlighting** - Add `?search=term` to highlight specific content
|
- **Search highlighting** - Add `?search=term` to highlight specific content
|
||||||
|
|
|
||||||
398
frontend/app.js
398
frontend/app.js
|
|
@ -32,12 +32,103 @@ const ErrorHandler = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized filename validation
|
||||||
|
* Supports Unicode characters (international text) but blocks dangerous filesystem characters.
|
||||||
|
* Does NOT silently modify filenames - validates and returns status.
|
||||||
|
*/
|
||||||
|
const FilenameValidator = {
|
||||||
|
// Characters that are forbidden in filenames across Windows/macOS/Linux
|
||||||
|
// Windows: \ / : * ? " < > |
|
||||||
|
// macOS: / :
|
||||||
|
// Linux: / \0
|
||||||
|
// Common set to block (including control characters)
|
||||||
|
FORBIDDEN_CHARS: /[\\/:*?"<>|\x00-\x1f]/,
|
||||||
|
|
||||||
|
// For display purposes - human readable list
|
||||||
|
FORBIDDEN_CHARS_DISPLAY: '\\ / : * ? " < > |',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a filename (single segment, no path separators)
|
||||||
|
* @param {string} name - The filename to validate
|
||||||
|
* @returns {{ valid: boolean, error?: string, sanitized?: string }}
|
||||||
|
*/
|
||||||
|
validateFilename(name) {
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return { valid: false, error: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { valid: false, error: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for forbidden characters
|
||||||
|
if (this.FORBIDDEN_CHARS.test(trimmed)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'forbidden_chars',
|
||||||
|
forbiddenChars: this.FORBIDDEN_CHARS_DISPLAY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reserved Windows names (case-insensitive)
|
||||||
|
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
|
||||||
|
if (reservedNames.test(trimmed)) {
|
||||||
|
return { valid: false, error: 'reserved_name' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for names starting/ending with dots or spaces (problematic on some systems)
|
||||||
|
if (trimmed.startsWith('.') && trimmed.length === 1) {
|
||||||
|
return { valid: false, error: 'invalid_dot' };
|
||||||
|
}
|
||||||
|
if (trimmed.endsWith('.') || trimmed.endsWith(' ')) {
|
||||||
|
return { valid: false, error: 'trailing_dot_space' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, sanitized: trimmed };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a path (may contain forward slashes for folder separators)
|
||||||
|
* @param {string} path - The path to validate
|
||||||
|
* @returns {{ valid: boolean, error?: string, sanitized?: string }}
|
||||||
|
*/
|
||||||
|
validatePath(path) {
|
||||||
|
if (!path || typeof path !== 'string') {
|
||||||
|
return { valid: false, error: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { valid: false, error: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by forward slash and validate each segment
|
||||||
|
const segments = trimmed.split('/').filter(s => s.length > 0);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return { valid: false, error: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const result = this.validateFilename(segment);
|
||||||
|
if (!result.valid) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild path without empty segments
|
||||||
|
return { valid: true, sanitized: segments.join('/') };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function noteApp() {
|
function noteApp() {
|
||||||
return {
|
return {
|
||||||
// App state
|
// App state
|
||||||
appName: 'NoteDiscovery',
|
appName: 'NoteDiscovery',
|
||||||
appVersion: '0.0.0',
|
appVersion: '0.0.0',
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
|
demoMode: false,
|
||||||
notes: [],
|
notes: [],
|
||||||
currentNote: '',
|
currentNote: '',
|
||||||
currentNoteName: '',
|
currentNoteName: '',
|
||||||
|
|
@ -111,6 +202,9 @@ function noteApp() {
|
||||||
tagsExpanded: false,
|
tagsExpanded: false,
|
||||||
tagReloadTimeout: null, // For debouncing tag reloads
|
tagReloadTimeout: null, // For debouncing tag reloads
|
||||||
|
|
||||||
|
// Outline (TOC) state
|
||||||
|
outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...]
|
||||||
|
|
||||||
// Scroll sync state
|
// Scroll sync state
|
||||||
isScrolling: false,
|
isScrolling: false,
|
||||||
|
|
||||||
|
|
@ -382,6 +476,7 @@ function noteApp() {
|
||||||
this.currentNote = '';
|
this.currentNote = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
this.currentNoteName = '';
|
this.currentNoteName = '';
|
||||||
|
this.outline = [];
|
||||||
|
|
||||||
// Restore homepage folder state if it was saved
|
// Restore homepage folder state if it was saved
|
||||||
if (e.state && e.state.homepageFolder !== undefined) {
|
if (e.state && e.state.homepageFolder !== undefined) {
|
||||||
|
|
@ -569,6 +664,7 @@ function noteApp() {
|
||||||
this.appName = config.name;
|
this.appName = config.name;
|
||||||
this.appVersion = config.version || '0.0.0';
|
this.appVersion = config.version || '0.0.0';
|
||||||
this.authEnabled = config.authentication?.enabled || false;
|
this.authEnabled = config.authentication?.enabled || false;
|
||||||
|
this.demoMode = config.demoMode || false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load config:', error);
|
console.error('Failed to load config:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -802,6 +898,35 @@ function noteApp() {
|
||||||
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`);
|
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get localized error message from FilenameValidator result
|
||||||
|
* @param {object} validation - The validation result from FilenameValidator
|
||||||
|
* @param {string} type - 'note' or 'folder'
|
||||||
|
* @returns {string} Localized error message
|
||||||
|
*/
|
||||||
|
getValidationErrorMessage(validation, type = 'note') {
|
||||||
|
switch (validation.error) {
|
||||||
|
case 'empty':
|
||||||
|
return type === 'note'
|
||||||
|
? this.t('notes.empty_name')
|
||||||
|
: this.t('folders.invalid_name');
|
||||||
|
case 'forbidden_chars':
|
||||||
|
return this.t('validation.forbidden_chars', {
|
||||||
|
chars: validation.forbiddenChars
|
||||||
|
});
|
||||||
|
case 'reserved_name':
|
||||||
|
return this.t('validation.reserved_name');
|
||||||
|
case 'invalid_dot':
|
||||||
|
return this.t('validation.invalid_dot');
|
||||||
|
case 'trailing_dot_space':
|
||||||
|
return this.t('validation.trailing_dot_space');
|
||||||
|
default:
|
||||||
|
return type === 'note'
|
||||||
|
? this.t('notes.invalid_name')
|
||||||
|
: this.t('folders.invalid_name');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Load available locales from backend
|
// Load available locales from backend
|
||||||
async loadAvailableLocales() {
|
async loadAvailableLocales() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -968,8 +1093,15 @@ function noteApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Validate the note name
|
||||||
|
const validation = FilenameValidator.validateFilename(this.newTemplateNoteName);
|
||||||
|
if (!validation.valid) {
|
||||||
|
alert(this.getValidationErrorMessage(validation, 'note'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine the note path based on dropdown context
|
// Determine the note path based on dropdown context
|
||||||
let notePath = this.newTemplateNoteName.trim();
|
let notePath = validation.sanitized;
|
||||||
if (!notePath.endsWith('.md')) {
|
if (!notePath.endsWith('.md')) {
|
||||||
notePath += '.md';
|
notePath += '.md';
|
||||||
}
|
}
|
||||||
|
|
@ -990,7 +1122,7 @@ function noteApp() {
|
||||||
// CRITICAL: Check if note already exists
|
// CRITICAL: Check if note already exists
|
||||||
const existingNote = this.notes.find(note => note.path === notePath);
|
const existingNote = this.notes.find(note => note.path === notePath);
|
||||||
if (existingNote) {
|
if (existingNote) {
|
||||||
alert(this.t('notes.already_exists', { name: this.newTemplateNoteName.trim() }));
|
alert(this.t('notes.already_exists', { name: validation.sanitized }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1034,6 +1166,121 @@ function noteApp() {
|
||||||
this.applyFilters();
|
this.applyFilters();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Outline (TOC) Methods
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
// Extract headings from markdown content for the outline
|
||||||
|
extractOutline(content) {
|
||||||
|
if (!content) {
|
||||||
|
this.outline = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headings = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const slugCounts = {}; // Track duplicate slugs
|
||||||
|
|
||||||
|
// Skip frontmatter
|
||||||
|
let inFrontmatter = false;
|
||||||
|
let frontmatterEnded = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Handle frontmatter
|
||||||
|
if (i === 0 && line.trim() === '---') {
|
||||||
|
inFrontmatter = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFrontmatter) {
|
||||||
|
if (line.trim() === '---') {
|
||||||
|
inFrontmatter = false;
|
||||||
|
frontmatterEnded = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match heading lines (# to ######)
|
||||||
|
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const level = match[1].length;
|
||||||
|
const text = match[2].trim();
|
||||||
|
|
||||||
|
// Generate slug (GitHub-style)
|
||||||
|
let slug = text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '') // Remove special chars
|
||||||
|
.replace(/\s+/g, '-') // Spaces to dashes
|
||||||
|
.replace(/-+/g, '-'); // Multiple dashes to single
|
||||||
|
|
||||||
|
// Handle duplicate slugs
|
||||||
|
if (slugCounts[slug] !== undefined) {
|
||||||
|
slugCounts[slug]++;
|
||||||
|
slug = `${slug}-${slugCounts[slug]}`;
|
||||||
|
} else {
|
||||||
|
slugCounts[slug] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
headings.push({
|
||||||
|
level,
|
||||||
|
text,
|
||||||
|
slug,
|
||||||
|
line: i + 1 // 1-indexed line number
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.outline = headings;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scroll to a heading in the editor or preview
|
||||||
|
scrollToHeading(heading) {
|
||||||
|
if (this.viewMode === 'preview' || this.viewMode === 'split') {
|
||||||
|
// In preview/split mode, scroll the preview pane
|
||||||
|
const preview = document.querySelector('.markdown-preview');
|
||||||
|
if (preview) {
|
||||||
|
// Find the heading element by text content (more reliable than ID)
|
||||||
|
const headingElements = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
|
for (const el of headingElements) {
|
||||||
|
if (el.textContent.trim() === heading.text) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
// Add a brief highlight effect
|
||||||
|
el.style.transition = 'background-color 0.3s';
|
||||||
|
el.style.backgroundColor = 'var(--accent-light)';
|
||||||
|
setTimeout(() => {
|
||||||
|
el.style.backgroundColor = '';
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.viewMode === 'edit' || this.viewMode === 'split') {
|
||||||
|
// In edit/split mode, scroll the editor to the line
|
||||||
|
const textarea = document.querySelector('.editor-textarea');
|
||||||
|
if (textarea && heading.line) {
|
||||||
|
const lines = textarea.value.split('\n');
|
||||||
|
let charPos = 0;
|
||||||
|
|
||||||
|
// Calculate character position of the heading line
|
||||||
|
for (let i = 0; i < heading.line - 1 && i < lines.length; i++) {
|
||||||
|
charPos += lines[i].length + 1; // +1 for newline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cursor position and scroll
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(charPos, charPos);
|
||||||
|
|
||||||
|
// Calculate scroll position (approximate)
|
||||||
|
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24;
|
||||||
|
const scrollTop = (heading.line - 1) * lineHeight - textarea.clientHeight / 3;
|
||||||
|
textarea.scrollTop = Math.max(0, scrollTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Unified filtering logic combining tags and text search
|
// Unified filtering logic combining tags and text search
|
||||||
async applyFilters() {
|
async applyFilters() {
|
||||||
const hasTextSearch = this.searchQuery.trim().length > 0;
|
const hasTextSearch = this.searchQuery.trim().length > 0;
|
||||||
|
|
@ -1945,42 +2192,79 @@ function noteApp() {
|
||||||
// Prevent default navigation for internal links
|
// Prevent default navigation for internal links
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// Remove any anchor from the href (e.g., "note.md#section" -> "note.md")
|
// Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section")
|
||||||
// Also decode URL encoding (e.g., "note%203.md" -> "note 3.md")
|
const decodedHref = decodeURIComponent(href);
|
||||||
const notePath = decodeURIComponent(href.split('#')[0]);
|
const hashIndex = decodedHref.indexOf('#');
|
||||||
|
const notePath = hashIndex !== -1 ? decodedHref.substring(0, hashIndex) : decodedHref;
|
||||||
|
const anchor = hashIndex !== -1 ? decodedHref.substring(hashIndex + 1) : null;
|
||||||
|
|
||||||
// Skip if it's just an anchor link
|
// If it's just an anchor link (#heading), scroll within current note
|
||||||
|
if (!notePath && anchor) {
|
||||||
|
this.scrollToAnchor(anchor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no path
|
||||||
if (!notePath) return;
|
if (!notePath) return;
|
||||||
|
|
||||||
// Find the note by path (try exact match first, then with .md extension)
|
// Find the note by path (try exact match first, then with .md extension)
|
||||||
const note = this.notes.find(n =>
|
let targetNote = this.notes.find(n =>
|
||||||
n.path === notePath ||
|
n.path === notePath ||
|
||||||
n.path === notePath + '.md'
|
n.path === notePath + '.md'
|
||||||
);
|
);
|
||||||
if (note) {
|
|
||||||
this.loadNote(note.path);
|
if (!targetNote) {
|
||||||
} else {
|
|
||||||
// Try to find by name (in case link uses just the note name without path)
|
// Try to find by name (in case link uses just the note name without path)
|
||||||
const noteByName = this.notes.find(n =>
|
targetNote = this.notes.find(n =>
|
||||||
n.name === notePath ||
|
n.name === notePath ||
|
||||||
n.name === notePath + '.md' ||
|
n.name === notePath + '.md' ||
|
||||||
// Also match by filename at end of path (case-insensitive)
|
|
||||||
n.name.toLowerCase() === notePath.toLowerCase() ||
|
n.name.toLowerCase() === notePath.toLowerCase() ||
|
||||||
n.name.toLowerCase() === (notePath + '.md').toLowerCase()
|
n.name.toLowerCase() === (notePath + '.md').toLowerCase()
|
||||||
);
|
);
|
||||||
if (noteByName) {
|
}
|
||||||
this.loadNote(noteByName.path);
|
|
||||||
} else {
|
if (!targetNote) {
|
||||||
// Last resort: case-insensitive path matching
|
// Last resort: case-insensitive path matching
|
||||||
const noteByPathCI = this.notes.find(n =>
|
targetNote = this.notes.find(n =>
|
||||||
n.path.toLowerCase() === notePath.toLowerCase() ||
|
n.path.toLowerCase() === notePath.toLowerCase() ||
|
||||||
n.path.toLowerCase() === (notePath + '.md').toLowerCase()
|
n.path.toLowerCase() === (notePath + '.md').toLowerCase()
|
||||||
);
|
);
|
||||||
if (noteByPathCI) {
|
}
|
||||||
this.loadNote(noteByPathCI.path);
|
|
||||||
} else {
|
if (targetNote) {
|
||||||
alert(this.t('notes.not_found', { path: notePath }));
|
// Load the note, then scroll to anchor if present
|
||||||
|
this.loadNote(targetNote.path).then(() => {
|
||||||
|
if (anchor) {
|
||||||
|
// Small delay to ensure content is rendered
|
||||||
|
setTimeout(() => this.scrollToAnchor(anchor), 100);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert(this.t('notes.not_found', { path: notePath }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scroll to an anchor (heading) by slug - reuses outline data
|
||||||
|
scrollToAnchor(anchor) {
|
||||||
|
// Normalize the anchor (GitHub-style slug)
|
||||||
|
const targetSlug = anchor
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
|
||||||
|
// Find matching heading in outline
|
||||||
|
const heading = this.outline.find(h => h.slug === targetSlug);
|
||||||
|
|
||||||
|
if (heading) {
|
||||||
|
this.scrollToHeading(heading);
|
||||||
|
} else {
|
||||||
|
// Fallback: try to find heading by exact text match
|
||||||
|
const headingByText = this.outline.find(h =>
|
||||||
|
h.text.toLowerCase().replace(/\s+/g, '-') === anchor.toLowerCase()
|
||||||
|
);
|
||||||
|
if (headingByText) {
|
||||||
|
this.scrollToHeading(headingByText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -2182,6 +2466,9 @@ function noteApp() {
|
||||||
this.currentImage = ''; // Clear image viewer when loading a note
|
this.currentImage = ''; // Clear image viewer when loading a note
|
||||||
this.lastSaved = false;
|
this.lastSaved = false;
|
||||||
|
|
||||||
|
// Extract outline for TOC panel
|
||||||
|
this.extractOutline(data.content);
|
||||||
|
|
||||||
// Initialize undo/redo history for this note (with cursor at start)
|
// Initialize undo/redo history for this note (with cursor at start)
|
||||||
this.undoHistory = [{ content: data.content, cursorPos: 0 }];
|
this.undoHistory = [{ content: data.content, cursorPos: 0 }];
|
||||||
this.redoHistory = [];
|
this.redoHistory = [];
|
||||||
|
|
@ -2548,23 +2835,29 @@ function noteApp() {
|
||||||
const noteName = prompt(promptText);
|
const noteName = prompt(promptText);
|
||||||
if (!noteName) return;
|
if (!noteName) return;
|
||||||
|
|
||||||
const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, '');
|
// Validate the name/path (may contain / for paths when no target folder)
|
||||||
if (!sanitizedName) {
|
const validation = targetFolder
|
||||||
alert(this.t('notes.invalid_name'));
|
? FilenameValidator.validateFilename(noteName)
|
||||||
|
: FilenameValidator.validatePath(noteName);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
alert(this.getValidationErrorMessage(validation, 'note'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validatedName = validation.sanitized;
|
||||||
|
|
||||||
let notePath;
|
let notePath;
|
||||||
if (targetFolder) {
|
if (targetFolder) {
|
||||||
notePath = `${targetFolder}/${sanitizedName}.md`;
|
notePath = `${targetFolder}/${validatedName}.md`;
|
||||||
} else {
|
} else {
|
||||||
notePath = sanitizedName.endsWith('.md') ? sanitizedName : `${sanitizedName}.md`;
|
notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: Check if note already exists
|
// CRITICAL: Check if note already exists
|
||||||
const existingNote = this.notes.find(note => note.path === notePath);
|
const existingNote = this.notes.find(note => note.path === notePath);
|
||||||
if (existingNote) {
|
if (existingNote) {
|
||||||
alert(this.t('notes.already_exists', { name: sanitizedName }));
|
alert(this.t('notes.already_exists', { name: validatedName }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2609,18 +2902,23 @@ function noteApp() {
|
||||||
const folderName = prompt(promptText);
|
const folderName = prompt(promptText);
|
||||||
if (!folderName) return;
|
if (!folderName) return;
|
||||||
|
|
||||||
const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, '');
|
// Validate the name/path (may contain / for paths when no target folder)
|
||||||
if (!sanitizedName) {
|
const validation = targetFolder
|
||||||
alert(this.t('folders.invalid_name'));
|
? FilenameValidator.validateFilename(folderName)
|
||||||
|
: FilenameValidator.validatePath(folderName);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
alert(this.getValidationErrorMessage(validation, 'folder'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderPath = targetFolder ? `${targetFolder}/${sanitizedName}` : sanitizedName;
|
const validatedName = validation.sanitized;
|
||||||
|
const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
|
||||||
|
|
||||||
// Check if folder already exists
|
// Check if folder already exists
|
||||||
const existingFolder = this.allFolders.find(folder => folder === folderPath);
|
const existingFolder = this.allFolders.find(folder => folder === folderPath);
|
||||||
if (existingFolder) {
|
if (existingFolder) {
|
||||||
alert(this.t('folders.already_exists', { name: sanitizedName }));
|
alert(this.t('folders.already_exists', { name: validatedName }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2653,15 +2951,18 @@ function noteApp() {
|
||||||
const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName);
|
const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName);
|
||||||
if (!newName || newName === currentName) return;
|
if (!newName || newName === currentName) return;
|
||||||
|
|
||||||
const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, '');
|
// Validate the new name (single segment, no path separators)
|
||||||
if (!sanitizedName) {
|
const validation = FilenameValidator.validateFilename(newName);
|
||||||
alert(this.t('folders.invalid_name'));
|
if (!validation.valid) {
|
||||||
|
alert(this.getValidationErrorMessage(validation, 'folder'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validatedName = validation.sanitized;
|
||||||
|
|
||||||
// Calculate new path
|
// Calculate new path
|
||||||
const pathParts = folderPath.split('/');
|
const pathParts = folderPath.split('/');
|
||||||
pathParts[pathParts.length - 1] = sanitizedName;
|
pathParts[pathParts.length - 1] = validatedName;
|
||||||
const newPath = pathParts.join('/');
|
const newPath = pathParts.join('/');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -2772,6 +3073,9 @@ function noteApp() {
|
||||||
// Parse metadata in real-time
|
// Parse metadata in real-time
|
||||||
this.parseMetadata();
|
this.parseMetadata();
|
||||||
|
|
||||||
|
// Update outline (TOC) in real-time
|
||||||
|
this.extractOutline(this.noteContent);
|
||||||
|
|
||||||
this.saveTimeout = setTimeout(() => {
|
this.saveTimeout = setTimeout(() => {
|
||||||
this.saveNote();
|
this.saveNote();
|
||||||
}, CONFIG.AUTOSAVE_DELAY);
|
}, CONFIG.AUTOSAVE_DELAY);
|
||||||
|
|
@ -3025,15 +3329,25 @@ function noteApp() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the new name (single segment, no path separators)
|
||||||
|
const validation = FilenameValidator.validateFilename(newName);
|
||||||
|
if (!validation.valid) {
|
||||||
|
alert(this.getValidationErrorMessage(validation, 'note'));
|
||||||
|
// Reset the name in the UI
|
||||||
|
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedName = validation.sanitized;
|
||||||
const folder = oldPath.split('/').slice(0, -1).join('/');
|
const folder = oldPath.split('/').slice(0, -1).join('/');
|
||||||
const newPath = folder ? `${folder}/${newName}.md` : `${newName}.md`;
|
const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`;
|
||||||
|
|
||||||
if (oldPath === newPath) return;
|
if (oldPath === newPath) return;
|
||||||
|
|
||||||
// Check if a note with the new name already exists
|
// Check if a note with the new name already exists
|
||||||
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
|
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
|
||||||
if (existingNote) {
|
if (existingNote) {
|
||||||
alert(this.t('notes.already_exists', { name: newName }));
|
alert(this.t('notes.already_exists', { name: validatedName }));
|
||||||
// Reset the name in the UI
|
// Reset the name in the UI
|
||||||
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
|
this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
|
||||||
return;
|
return;
|
||||||
|
|
@ -4489,6 +4803,7 @@ function noteApp() {
|
||||||
this.currentNoteName = '';
|
this.currentNoteName = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
this.currentImage = '';
|
this.currentImage = '';
|
||||||
|
this.outline = [];
|
||||||
|
|
||||||
// Invalidate cache to force recalculation
|
// Invalidate cache to force recalculation
|
||||||
this._homepageCache = {
|
this._homepageCache = {
|
||||||
|
|
@ -4509,6 +4824,7 @@ function noteApp() {
|
||||||
this.currentNoteName = '';
|
this.currentNoteName = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
this.currentImage = '';
|
this.currentImage = '';
|
||||||
|
this.outline = [];
|
||||||
this.mobileSidebarOpen = false;
|
this.mobileSidebarOpen = false;
|
||||||
|
|
||||||
// Invalidate cache to force recalculation
|
// Invalidate cache to force recalculation
|
||||||
|
|
|
||||||
|
|
@ -1157,7 +1157,7 @@
|
||||||
:class="{'active': activePanel === 'files'}"
|
:class="{'active': activePanel === 'files'}"
|
||||||
@click="activePanel = 'files'"
|
@click="activePanel = 'files'"
|
||||||
:title="t('sidebar.files')"
|
:title="t('sidebar.files')"
|
||||||
aria-label="Files panel"
|
:aria-label="t('sidebar.files')"
|
||||||
>
|
>
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||||
|
|
@ -1170,7 +1170,7 @@
|
||||||
:class="{'active': activePanel === 'search'}"
|
:class="{'active': activePanel === 'search'}"
|
||||||
@click="activePanel = 'search'"
|
@click="activePanel = 'search'"
|
||||||
:title="t('sidebar.search')"
|
:title="t('sidebar.search')"
|
||||||
aria-label="Search panel"
|
:aria-label="t('sidebar.search')"
|
||||||
>
|
>
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg 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>
|
||||||
|
|
@ -1191,7 +1191,7 @@
|
||||||
:class="{'active': activePanel === 'tags'}"
|
:class="{'active': activePanel === 'tags'}"
|
||||||
@click="activePanel = 'tags'"
|
@click="activePanel = 'tags'"
|
||||||
:title="t('tags.title')"
|
:title="t('tags.title')"
|
||||||
aria-label="Tags panel"
|
:aria-label="t('tags.title')"
|
||||||
>
|
>
|
||||||
<svg fill="currentColor" viewBox="0 0 20 20">
|
<svg fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||||
|
|
@ -1206,6 +1206,27 @@
|
||||||
></span>
|
></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Outline (TOC) -->
|
||||||
|
<button
|
||||||
|
class="icon-rail-btn relative"
|
||||||
|
:class="{'active': activePanel === 'outline'}"
|
||||||
|
@click="activePanel = 'outline'"
|
||||||
|
:title="t('outline.title')"
|
||||||
|
:aria-label="t('outline.title')"
|
||||||
|
>
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
<!-- Heading count badge -->
|
||||||
|
<span
|
||||||
|
x-show="outline.length > 0"
|
||||||
|
x-cloak
|
||||||
|
class="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-bold rounded-full flex items-center justify-center"
|
||||||
|
style="background-color: var(--accent-primary); color: white;"
|
||||||
|
x-text="outline.length > 9 ? '9+' : outline.length"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="icon-rail-spacer"></div>
|
<div class="icon-rail-spacer"></div>
|
||||||
|
|
||||||
<!-- Graph -->
|
<!-- Graph -->
|
||||||
|
|
@ -1214,7 +1235,7 @@
|
||||||
:class="{'active': showGraph}"
|
:class="{'active': showGraph}"
|
||||||
@click="showGraph = true; initGraph()"
|
@click="showGraph = true; initGraph()"
|
||||||
:title="t('graph.title')"
|
:title="t('graph.title')"
|
||||||
aria-label="Graph view"
|
:aria-label="t('graph.title')"
|
||||||
>
|
>
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
|
|
@ -1227,7 +1248,7 @@
|
||||||
:class="{'active': activePanel === 'settings'}"
|
:class="{'active': activePanel === 'settings'}"
|
||||||
@click="activePanel = 'settings'"
|
@click="activePanel = 'settings'"
|
||||||
:title="t('sidebar.settings')"
|
:title="t('sidebar.settings')"
|
||||||
aria-label="Settings panel"
|
:aria-label="t('sidebar.settings')"
|
||||||
>
|
>
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
|
@ -1578,6 +1599,51 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- END TAGS PANEL -->
|
<!-- END TAGS PANEL -->
|
||||||
|
|
||||||
|
<!-- ==================== OUTLINE PANEL ==================== -->
|
||||||
|
<div x-show="activePanel === 'outline'" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="flex flex-col h-full">
|
||||||
|
<!-- Outline Header -->
|
||||||
|
<div class="flex-shrink-0 px-3 py-2 border-b flex items-center justify-between" style="border-color: var(--border-primary);">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);" x-text="t('outline.title')"></span>
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded" style="background-color: var(--bg-tertiary); color: var(--text-tertiary);" x-text="outline.length"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable content: Outline items -->
|
||||||
|
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
|
<!-- Outline items -->
|
||||||
|
<div class="py-2">
|
||||||
|
<template x-if="outline.length > 0">
|
||||||
|
<div>
|
||||||
|
<template x-for="(heading, index) in outline" :key="index">
|
||||||
|
<button
|
||||||
|
@click="scrollToHeading(heading)"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm transition-colors hover:brightness-110"
|
||||||
|
:style="`padding-left: ${(heading.level - 1) * 12 + 12}px; color: var(--text-primary);`"
|
||||||
|
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
||||||
|
onmouseout="this.style.backgroundColor='transparent'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block truncate"
|
||||||
|
:style="heading.level === 1 ? 'font-weight: 600;' : (heading.level === 2 ? 'font-weight: 500;' : '')"
|
||||||
|
x-text="heading.text"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="outline.length === 0">
|
||||||
|
<div class="text-center py-6 px-3">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" 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="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm mb-2" style="color: var(--text-tertiary);" x-text="t('outline.no_headings')"></p>
|
||||||
|
<p class="text-xs" style="color: var(--text-tertiary);" x-text="t('outline.hint')"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- END OUTLINE PANEL -->
|
||||||
|
|
||||||
<!-- ==================== SETTINGS PANEL ==================== -->
|
<!-- ==================== SETTINGS PANEL ==================== -->
|
||||||
<template x-if="activePanel === 'settings'">
|
<template x-if="activePanel === 'settings'">
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
|
|
@ -1668,6 +1734,21 @@
|
||||||
>
|
>
|
||||||
notediscovery.com
|
notediscovery.com
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Ko-fi support link (demo mode only) -->
|
||||||
|
<template x-if="demoMode">
|
||||||
|
<div class="mt-3">
|
||||||
|
<a
|
||||||
|
href="https://ko-fi.com/gamosoft"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full transition-all hover:scale-105"
|
||||||
|
style="background-color: #ff5e5b; color: white;"
|
||||||
|
>
|
||||||
|
<span>☕</span>
|
||||||
|
<span x-text="t('support.enjoying_demo')"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2590,23 +2671,23 @@
|
||||||
class="mobile-bottom-tab"
|
class="mobile-bottom-tab"
|
||||||
:class="{'active': activePanel === 'files'}"
|
:class="{'active': activePanel === 'files'}"
|
||||||
@click="activePanel = 'files'; mobileSidebarOpen = true;"
|
@click="activePanel = 'files'; mobileSidebarOpen = true;"
|
||||||
aria-label="Files"
|
:aria-label="t('sidebar.files')"
|
||||||
>
|
>
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Files</span>
|
<span x-text="t('sidebar.files')"></span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="mobile-bottom-tab relative"
|
class="mobile-bottom-tab relative"
|
||||||
:class="{'active': activePanel === 'search'}"
|
:class="{'active': activePanel === 'search'}"
|
||||||
@click="activePanel = 'search'; mobileSidebarOpen = true;"
|
@click="activePanel = 'search'; mobileSidebarOpen = true;"
|
||||||
aria-label="Search"
|
:aria-label="t('sidebar.search')"
|
||||||
>
|
>
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<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>
|
||||||
<span>Search</span>
|
<span x-text="t('sidebar.search')"></span>
|
||||||
<!-- Search results badge -->
|
<!-- Search results badge -->
|
||||||
<span
|
<span
|
||||||
x-show="searchQuery.trim() && searchResults.length > 0"
|
x-show="searchQuery.trim() && searchResults.length > 0"
|
||||||
|
|
@ -2620,12 +2701,12 @@
|
||||||
class="mobile-bottom-tab relative"
|
class="mobile-bottom-tab relative"
|
||||||
:class="{'active': activePanel === 'tags'}"
|
:class="{'active': activePanel === 'tags'}"
|
||||||
@click="activePanel = 'tags'; mobileSidebarOpen = true;"
|
@click="activePanel = 'tags'; mobileSidebarOpen = true;"
|
||||||
aria-label="Tags"
|
:aria-label="t('tags.title')"
|
||||||
>
|
>
|
||||||
<svg fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
<svg fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Tags</span>
|
<span x-text="t('tags.title')"></span>
|
||||||
<!-- Filtered notes badge (shows result count when tags selected) -->
|
<!-- Filtered notes badge (shows result count when tags selected) -->
|
||||||
<span
|
<span
|
||||||
x-show="selectedTags.length > 0"
|
x-show="selectedTags.length > 0"
|
||||||
|
|
@ -2639,24 +2720,24 @@
|
||||||
class="mobile-bottom-tab"
|
class="mobile-bottom-tab"
|
||||||
:class="{'active': showGraph}"
|
:class="{'active': showGraph}"
|
||||||
@click="showGraph = true; initGraph(); mobileSidebarOpen = false;"
|
@click="showGraph = true; initGraph(); mobileSidebarOpen = false;"
|
||||||
aria-label="Graph"
|
:aria-label="t('graph.title')"
|
||||||
>
|
>
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Graph</span>
|
<span x-text="t('graph.title')"></span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="mobile-bottom-tab"
|
class="mobile-bottom-tab"
|
||||||
:class="{'active': activePanel === 'settings'}"
|
:class="{'active': activePanel === 'settings'}"
|
||||||
@click="activePanel = 'settings'; mobileSidebarOpen = true;"
|
@click="activePanel = 'settings'; mobileSidebarOpen = true;"
|
||||||
aria-label="Settings"
|
:aria-label="t('sidebar.settings')"
|
||||||
>
|
>
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Settings</span>
|
<span x-text="t('sidebar.settings')"></span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,12 @@
|
||||||
"filter_by": "Nach {{tag}} filtern ({{count}} Notizen)"
|
"filter_by": "Nach {{tag}} filtern ({{count}} Notizen)"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"outline": {
|
||||||
|
"title": "Gliederung",
|
||||||
|
"no_headings": "Keine Überschriften gefunden",
|
||||||
|
"hint": "Überschriften mit # hinzufügen"
|
||||||
|
},
|
||||||
|
|
||||||
"stats": {
|
"stats": {
|
||||||
"words": "Wörter",
|
"words": "Wörter",
|
||||||
"reading_time": "~{{minutes}}m Lesezeit",
|
"reading_time": "~{{minutes}}m Lesezeit",
|
||||||
|
|
@ -211,6 +217,17 @@
|
||||||
"note_plural": "Notizen",
|
"note_plural": "Notizen",
|
||||||
"folder_singular": "Ordner",
|
"folder_singular": "Ordner",
|
||||||
"folder_plural": "Ordner"
|
"folder_plural": "Ordner"
|
||||||
|
},
|
||||||
|
|
||||||
|
"validation": {
|
||||||
|
"forbidden_chars": "Der Name enthält unzulässige Zeichen: {{chars}}",
|
||||||
|
"reserved_name": "Dieser Name ist vom Betriebssystem reserviert.",
|
||||||
|
"invalid_dot": "Der Name darf nicht nur ein Punkt sein.",
|
||||||
|
"trailing_dot_space": "Der Name darf nicht mit einem Punkt oder Leerzeichen enden."
|
||||||
|
},
|
||||||
|
|
||||||
|
"support": {
|
||||||
|
"enjoying_demo": "Unterstütze dieses Projekt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,12 @@
|
||||||
"filter_by": "Filter by {{tag}} ({{count}} notes)"
|
"filter_by": "Filter by {{tag}} ({{count}} notes)"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"outline": {
|
||||||
|
"title": "Outline",
|
||||||
|
"no_headings": "No headings found",
|
||||||
|
"hint": "Add headings using # syntax"
|
||||||
|
},
|
||||||
|
|
||||||
"stats": {
|
"stats": {
|
||||||
"words": "words",
|
"words": "words",
|
||||||
"reading_time": "~{{minutes}}m read",
|
"reading_time": "~{{minutes}}m read",
|
||||||
|
|
@ -211,6 +217,17 @@
|
||||||
"note_plural": "notes",
|
"note_plural": "notes",
|
||||||
"folder_singular": "folder",
|
"folder_singular": "folder",
|
||||||
"folder_plural": "folders"
|
"folder_plural": "folders"
|
||||||
|
},
|
||||||
|
|
||||||
|
"validation": {
|
||||||
|
"forbidden_chars": "Name contains forbidden characters: {{chars}}",
|
||||||
|
"reserved_name": "This name is reserved by the operating system.",
|
||||||
|
"invalid_dot": "Name cannot be just a dot.",
|
||||||
|
"trailing_dot_space": "Name cannot end with a dot or space."
|
||||||
|
},
|
||||||
|
|
||||||
|
"support": {
|
||||||
|
"enjoying_demo": "Support this project"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,12 @@
|
||||||
"filter_by": "Filtrar por {{tag}} ({{count}} notas)"
|
"filter_by": "Filtrar por {{tag}} ({{count}} notas)"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"outline": {
|
||||||
|
"title": "Esquema",
|
||||||
|
"no_headings": "No se encontraron encabezados",
|
||||||
|
"hint": "Añade encabezados usando sintaxis #"
|
||||||
|
},
|
||||||
|
|
||||||
"stats": {
|
"stats": {
|
||||||
"words": "palabras",
|
"words": "palabras",
|
||||||
"reading_time": "~{{minutes}}m de lectura",
|
"reading_time": "~{{minutes}}m de lectura",
|
||||||
|
|
@ -211,6 +217,17 @@
|
||||||
"note_plural": "notas",
|
"note_plural": "notas",
|
||||||
"folder_singular": "carpeta",
|
"folder_singular": "carpeta",
|
||||||
"folder_plural": "carpetas"
|
"folder_plural": "carpetas"
|
||||||
|
},
|
||||||
|
|
||||||
|
"validation": {
|
||||||
|
"forbidden_chars": "El nombre contiene caracteres no permitidos: {{chars}}",
|
||||||
|
"reserved_name": "Este nombre está reservado por el sistema operativo.",
|
||||||
|
"invalid_dot": "El nombre no puede ser solo un punto.",
|
||||||
|
"trailing_dot_space": "El nombre no puede terminar con un punto o espacio."
|
||||||
|
},
|
||||||
|
|
||||||
|
"support": {
|
||||||
|
"enjoying_demo": "Apoya este proyecto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,12 @@
|
||||||
"filter_by": "Filtrer par {{tag}} ({{count}} notes)"
|
"filter_by": "Filtrer par {{tag}} ({{count}} notes)"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"outline": {
|
||||||
|
"title": "Plan",
|
||||||
|
"no_headings": "Aucun titre trouvé",
|
||||||
|
"hint": "Ajoutez des titres avec la syntaxe #"
|
||||||
|
},
|
||||||
|
|
||||||
"stats": {
|
"stats": {
|
||||||
"words": "mots",
|
"words": "mots",
|
||||||
"reading_time": "~{{minutes}}m de lecture",
|
"reading_time": "~{{minutes}}m de lecture",
|
||||||
|
|
@ -211,6 +217,17 @@
|
||||||
"note_plural": "notes",
|
"note_plural": "notes",
|
||||||
"folder_singular": "dossier",
|
"folder_singular": "dossier",
|
||||||
"folder_plural": "dossiers"
|
"folder_plural": "dossiers"
|
||||||
|
},
|
||||||
|
|
||||||
|
"validation": {
|
||||||
|
"forbidden_chars": "Le nom contient des caractères interdits : {{chars}}",
|
||||||
|
"reserved_name": "Ce nom est réservé par le système d'exploitation.",
|
||||||
|
"invalid_dot": "Le nom ne peut pas être juste un point.",
|
||||||
|
"trailing_dot_space": "Le nom ne peut pas se terminer par un point ou un espace."
|
||||||
|
},
|
||||||
|
|
||||||
|
"support": {
|
||||||
|
"enjoying_demo": "Soutenez ce projet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,12 @@
|
||||||
"filter_by": "按 {{tag}} 过滤 ({{count}} 条笔记)"
|
"filter_by": "按 {{tag}} 过滤 ({{count}} 条笔记)"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"outline": {
|
||||||
|
"title": "大纲",
|
||||||
|
"no_headings": "未找到标题",
|
||||||
|
"hint": "使用 # 语法添加标题"
|
||||||
|
},
|
||||||
|
|
||||||
"stats": {
|
"stats": {
|
||||||
"words": "字数",
|
"words": "字数",
|
||||||
"reading_time": "~{{minutes}}分钟阅读",
|
"reading_time": "~{{minutes}}分钟阅读",
|
||||||
|
|
@ -211,6 +217,17 @@
|
||||||
"note_plural": "笔记",
|
"note_plural": "笔记",
|
||||||
"folder_singular": "文件夹",
|
"folder_singular": "文件夹",
|
||||||
"folder_plural": "文件夹"
|
"folder_plural": "文件夹"
|
||||||
|
},
|
||||||
|
|
||||||
|
"validation": {
|
||||||
|
"forbidden_chars": "名称包含禁止的字符:{{chars}}",
|
||||||
|
"reserved_name": "此名称被操作系统保留。",
|
||||||
|
"invalid_dot": "名称不能只是一个点。",
|
||||||
|
"trailing_dot_space": "名称不能以点或空格结尾。"
|
||||||
|
},
|
||||||
|
|
||||||
|
"support": {
|
||||||
|
"enjoying_demo": "支持此项目"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue