added basic TOC handling

This commit is contained in:
Gamosoft 2025-12-16 16:55:05 +01:00
parent d44abdda72
commit 53e9eff2c2
7 changed files with 218 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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