From 53e9eff2c218a0d1d0780e767fc7a6fc9a32cda5 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 16 Dec 2025 16:55:05 +0100 Subject: [PATCH 1/7] added basic TOC handling --- config.yaml | 2 +- frontend/app.js | 127 ++++++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 66 +++++++++++++++++++++++ locales/de-DE.json | 6 +++ locales/en-US.json | 6 +++ locales/es-ES.json | 6 +++ locales/fr-FR.json | 6 +++ 7 files changed, 218 insertions(+), 1 deletion(-) diff --git a/config.yaml b/config.yaml index fdaf2a3..713c704 100644 --- a/config.yaml +++ b/config.yaml @@ -27,7 +27,7 @@ search: authentication: # Authentication settings # Set enabled to true to require login - enabled: true + enabled: false # ⚠️ SECURITY WARNING: Change these values before exposing to the internet! # Default values below are for LOCAL TESTING ONLY diff --git a/frontend/app.js b/frontend/app.js index facaa34..e7fcef2 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -111,6 +111,9 @@ function noteApp() { tagsExpanded: false, tagReloadTimeout: null, // For debouncing tag reloads + // Outline (TOC) state + outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...] + // Scroll sync state isScrolling: false, @@ -382,6 +385,7 @@ function noteApp() { this.currentNote = ''; this.noteContent = ''; this.currentNoteName = ''; + this.outline = []; // Restore homepage folder state if it was saved if (e.state && e.state.homepageFolder !== undefined) { @@ -1034,6 +1038,121 @@ function noteApp() { 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 async applyFilters() { const hasTextSearch = this.searchQuery.trim().length > 0; @@ -2182,6 +2301,9 @@ function noteApp() { this.currentImage = ''; // Clear image viewer when loading a note this.lastSaved = false; + // Extract outline for TOC panel + this.extractOutline(data.content); + // Initialize undo/redo history for this note (with cursor at start) this.undoHistory = [{ content: data.content, cursorPos: 0 }]; this.redoHistory = []; @@ -2772,6 +2894,9 @@ function noteApp() { // Parse metadata in real-time this.parseMetadata(); + // Update outline (TOC) in real-time + this.extractOutline(this.noteContent); + this.saveTimeout = setTimeout(() => { this.saveNote(); }, CONFIG.AUTOSAVE_DELAY); @@ -4489,6 +4614,7 @@ function noteApp() { this.currentNoteName = ''; this.noteContent = ''; this.currentImage = ''; + this.outline = []; // Invalidate cache to force recalculation this._homepageCache = { @@ -4509,6 +4635,7 @@ function noteApp() { this.currentNoteName = ''; this.noteContent = ''; this.currentImage = ''; + this.outline = []; this.mobileSidebarOpen = false; // Invalidate cache to force recalculation diff --git a/frontend/index.html b/frontend/index.html index 03b5a91..e778dd9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1206,6 +1206,27 @@ > + + +
@@ -1578,6 +1599,51 @@ + +
+ +
+ + +
+ + +
+ +
+ + +
+
+
+ +