added basic TOC handling
This commit is contained in:
parent
d44abdda72
commit
53e9eff2c2
|
|
@ -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
|
||||||
|
|
|
||||||
127
frontend/app.js
127
frontend/app.js
|
|
@ -111,6 +111,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 +385,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) {
|
||||||
|
|
@ -1034,6 +1038,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;
|
||||||
|
|
@ -2182,6 +2301,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 = [];
|
||||||
|
|
@ -2772,6 +2894,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);
|
||||||
|
|
@ -4489,6 +4614,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 +4635,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
|
||||||
|
|
|
||||||
|
|
@ -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="Outline panel"
|
||||||
|
>
|
||||||
|
<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 -->
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue