diff --git a/frontend/app.js b/frontend/app.js index 57e29d0..4ec83a7 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -43,6 +43,13 @@ const MIN_SEARCH_LENGTH = 2; const SEARCH_TIMEOUT_MS = 30000; + // Outline/TOC state + let outlineObserver = null; + let activeHeadingId = null; + let headingsCache = []; + let rightSidebarVisible = true; + let rightSidebarWidth = 280; + // --------------------------------------------------------------------------- // File extension → Lucide icon mapping // --------------------------------------------------------------------------- @@ -537,6 +544,409 @@ } } + // --------------------------------------------------------------------------- + // Outline/TOC Manager + // --------------------------------------------------------------------------- + + const OutlineManager = { + /** + * Slugify text to create valid IDs + */ + slugify(text) { + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim() || 'heading'; + }, + + /** + * Parse headings from markdown content + */ + parseHeadings() { + const contentArea = document.querySelector('.md-content'); + if (!contentArea) return []; + + const headings = []; + const h2s = contentArea.querySelectorAll('h2'); + const h3s = contentArea.querySelectorAll('h3'); + const allHeadings = [...h2s, ...h3s].sort((a, b) => { + return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + + const usedIds = new Map(); + + allHeadings.forEach((heading) => { + const text = heading.textContent.trim(); + if (!text) return; + + const level = parseInt(heading.tagName[1]); + let id = this.slugify(text); + + // Handle duplicate IDs + if (usedIds.has(id)) { + const count = usedIds.get(id) + 1; + usedIds.set(id, count); + id = `${id}-${count}`; + } else { + usedIds.set(id, 1); + } + + // Inject ID into heading if not present + if (!heading.id) { + heading.id = id; + } else { + id = heading.id; + } + + headings.push({ + id, + level, + text, + element: heading + }); + }); + + return headings; + }, + + /** + * Render outline list + */ + renderOutline(headings) { + const outlineList = document.getElementById('outline-list'); + const outlineEmpty = document.getElementById('outline-empty'); + + if (!outlineList) return; + + outlineList.innerHTML = ''; + + if (!headings || headings.length === 0) { + outlineList.hidden = true; + if (outlineEmpty) { + outlineEmpty.hidden = false; + safeCreateIcons(); + } + return; + } + + outlineList.hidden = false; + if (outlineEmpty) outlineEmpty.hidden = true; + + headings.forEach((heading) => { + const item = el('a', { + class: `outline-item level-${heading.level}`, + href: `#${heading.id}`, + 'data-heading-id': heading.id, + role: 'link' + }, [document.createTextNode(heading.text)]); + + item.addEventListener('click', (e) => { + e.preventDefault(); + this.scrollToHeading(heading.id); + }); + + outlineList.appendChild(item); + }); + + headingsCache = headings; + }, + + /** + * Scroll to heading with smooth behavior + */ + scrollToHeading(headingId) { + const heading = document.getElementById(headingId); + if (!heading) return; + + const contentArea = document.getElementById('content-area'); + if (!contentArea) return; + + // Calculate offset for fixed header (if any) + const headerHeight = 80; + const headingTop = heading.offsetTop; + + contentArea.scrollTo({ + top: headingTop - headerHeight, + behavior: 'smooth' + }); + + // Update active state immediately + this.setActiveHeading(headingId); + }, + + /** + * Set active heading in outline + */ + setActiveHeading(headingId) { + if (activeHeadingId === headingId) return; + + activeHeadingId = headingId; + + const items = document.querySelectorAll('.outline-item'); + items.forEach((item) => { + if (item.getAttribute('data-heading-id') === headingId) { + item.classList.add('active'); + item.setAttribute('aria-current', 'location'); + // Scroll outline item into view + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } else { + item.classList.remove('active'); + item.removeAttribute('aria-current'); + } + }); + }, + + /** + * Initialize outline for current document + */ + init() { + const headings = this.parseHeadings(); + this.renderOutline(headings); + ScrollSpyManager.init(headings); + ReadingProgressManager.init(); + }, + + /** + * Cleanup + */ + destroy() { + ScrollSpyManager.destroy(); + ReadingProgressManager.destroy(); + headingsCache = []; + activeHeadingId = null; + } + }; + + // --------------------------------------------------------------------------- + // Scroll Spy Manager + // --------------------------------------------------------------------------- + + const ScrollSpyManager = { + observer: null, + headings: [], + + init(headings) { + this.destroy(); + this.headings = headings; + + if (!headings || headings.length === 0) return; + + const contentArea = document.getElementById('content-area'); + if (!contentArea) return; + + const options = { + root: contentArea, + rootMargin: '-20% 0px -70% 0px', + threshold: [0, 0.3, 0.5, 1.0] + }; + + this.observer = new IntersectionObserver((entries) => { + // Find the most visible heading + let mostVisible = null; + let maxRatio = 0; + + entries.forEach((entry) => { + if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { + maxRatio = entry.intersectionRatio; + mostVisible = entry.target; + } + }); + + if (mostVisible && mostVisible.id) { + OutlineManager.setActiveHeading(mostVisible.id); + } + }, options); + + // Observe all headings + headings.forEach((heading) => { + if (heading.element) { + this.observer.observe(heading.element); + } + }); + }, + + destroy() { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + this.headings = []; + } + }; + + // --------------------------------------------------------------------------- + // Reading Progress Manager + // --------------------------------------------------------------------------- + + const ReadingProgressManager = { + scrollHandler: null, + + init() { + this.destroy(); + + const contentArea = document.getElementById('content-area'); + if (!contentArea) return; + + this.scrollHandler = this.throttle(() => { + this.updateProgress(); + }, 100); + + contentArea.addEventListener('scroll', this.scrollHandler); + this.updateProgress(); + }, + + updateProgress() { + const contentArea = document.getElementById('content-area'); + const progressFill = document.getElementById('reading-progress-fill'); + const progressText = document.getElementById('reading-progress-text'); + + if (!contentArea || !progressFill || !progressText) return; + + const scrollTop = contentArea.scrollTop; + const scrollHeight = contentArea.scrollHeight; + const clientHeight = contentArea.clientHeight; + + const maxScroll = scrollHeight - clientHeight; + const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0; + + progressFill.style.width = `${percentage}%`; + progressText.textContent = `${percentage}%`; + }, + + throttle(func, delay) { + let lastCall = 0; + return function(...args) { + const now = Date.now(); + if (now - lastCall >= delay) { + lastCall = now; + func.apply(this, args); + } + }; + }, + + destroy() { + const contentArea = document.getElementById('content-area'); + if (contentArea && this.scrollHandler) { + contentArea.removeEventListener('scroll', this.scrollHandler); + } + this.scrollHandler = null; + + // Reset progress + const progressFill = document.getElementById('reading-progress-fill'); + const progressText = document.getElementById('reading-progress-text'); + if (progressFill) progressFill.style.width = '0%'; + if (progressText) progressText.textContent = '0%'; + } + }; + + // --------------------------------------------------------------------------- + // Right Sidebar Manager + // --------------------------------------------------------------------------- + + const RightSidebarManager = { + init() { + this.loadState(); + this.initToggle(); + this.initResize(); + }, + + loadState() { + const savedVisible = localStorage.getItem('obsigate-right-sidebar-visible'); + const savedWidth = localStorage.getItem('obsigate-right-sidebar-width'); + + if (savedVisible !== null) { + rightSidebarVisible = savedVisible === 'true'; + } + + if (savedWidth) { + rightSidebarWidth = parseInt(savedWidth) || 280; + } + + this.applyState(); + }, + + applyState() { + const sidebar = document.getElementById('right-sidebar'); + const handle = document.getElementById('right-sidebar-resize-handle'); + + if (!sidebar) return; + + if (rightSidebarVisible) { + sidebar.classList.remove('hidden'); + sidebar.style.width = `${rightSidebarWidth}px`; + if (handle) handle.classList.remove('hidden'); + } else { + sidebar.classList.add('hidden'); + if (handle) handle.classList.add('hidden'); + } + }, + + toggle() { + rightSidebarVisible = !rightSidebarVisible; + localStorage.setItem('obsigate-right-sidebar-visible', rightSidebarVisible); + this.applyState(); + }, + + initToggle() { + const toggleBtn = document.getElementById('right-sidebar-toggle-btn'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => this.toggle()); + } + }, + + initResize() { + const handle = document.getElementById('right-sidebar-resize-handle'); + const sidebar = document.getElementById('right-sidebar'); + + if (!handle || !sidebar) return; + + let isResizing = false; + let startX = 0; + let startWidth = 0; + + const onMouseDown = (e) => { + isResizing = true; + startX = e.clientX; + startWidth = sidebar.offsetWidth; + handle.classList.add('active'); + document.body.style.cursor = 'ew-resize'; + document.body.style.userSelect = 'none'; + }; + + const onMouseMove = (e) => { + if (!isResizing) return; + + const delta = startX - e.clientX; + let newWidth = startWidth + delta; + + // Constrain width + newWidth = Math.max(200, Math.min(400, newWidth)); + + sidebar.style.width = `${newWidth}px`; + rightSidebarWidth = newWidth; + }; + + const onMouseUp = () => { + if (!isResizing) return; + + isResizing = false; + handle.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + localStorage.setItem('obsigate-right-sidebar-width', rightSidebarWidth); + }; + + handle.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + }; + // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- @@ -2249,6 +2659,9 @@ safeCreateIcons(); area.scrollTop = 0; + + // Initialize outline/TOC for this document + OutlineManager.init(); } // --------------------------------------------------------------------------- @@ -4369,6 +4782,7 @@ initSyncStatus(); initLoginForm(); initRecentTab(); + RightSidebarManager.init(); // Check auth status first const authOk = await AuthManager.initAuth(); diff --git a/frontend/index.html b/frontend/index.html index 0c7fe81..32b4df4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -337,6 +337,34 @@ + + + + + + diff --git a/frontend/style.css b/frontend/style.css index e208722..b3f4956 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -987,6 +987,228 @@ select { border-radius: 4px; } +/* --- Right Sidebar Resize Handle --- */ +.right-sidebar-resize-handle { + width: 5px; + cursor: ew-resize; + background: transparent; + flex-shrink: 0; + transition: background 150ms ease, opacity 300ms ease; + z-index: 10; +} +.right-sidebar-resize-handle:hover, +.right-sidebar-resize-handle.active { + background: var(--accent); + opacity: 0.5; +} +.right-sidebar-resize-handle.hidden { + width: 0; + opacity: 0; + pointer-events: none; +} + +/* --- Right Sidebar (Outline/TOC) --- */ +.right-sidebar { + width: 280px; + min-width: 200px; + max-width: 400px; + background: var(--bg-sidebar); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + transition: background 200ms ease, transform 300ms ease, width 300ms ease; + flex-shrink: 0; +} + +.right-sidebar.hidden { + transform: translateX(100%); + width: 0; + min-width: 0; + border-left: none; +} + +/* Right sidebar header */ +.right-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg-sidebar); + flex-shrink: 0; +} + +.right-sidebar-title { + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin: 0; +} + +.right-sidebar-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: all 150ms ease; +} + +.right-sidebar-toggle-btn:hover { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--accent); +} + +.right-sidebar-toggle-btn i { + transition: transform 200ms ease; +} + +.right-sidebar.hidden .right-sidebar-toggle-btn i { + transform: rotate(180deg); +} + +/* Outline panel */ +.outline-panel { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 0; + min-height: 0; +} + +.outline-panel::-webkit-scrollbar { + width: 6px; +} + +.outline-panel::-webkit-scrollbar-thumb { + background: var(--scrollbar); + border-radius: 3px; +} + +.outline-list { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 8px; +} + +.outline-item { + display: flex; + align-items: center; + padding: 8px 12px; + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + color: var(--text-secondary); + cursor: pointer; + transition: all 150ms ease; + position: relative; + text-decoration: none; + line-height: 1.4; + word-break: break-word; +} + +.outline-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.outline-item.active { + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font-weight: 500; +} + +.outline-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--accent); + border-radius: 0 2px 2px 0; +} + +/* Heading levels */ +.outline-item.level-2 { + font-weight: 600; + font-size: 0.85rem; + padding-left: 12px; +} + +.outline-item.level-3 { + font-weight: 400; + font-size: 0.8rem; + padding-left: 28px; + color: var(--text-muted); +} + +.outline-item.level-3:hover { + color: var(--text-secondary); +} + +.outline-item.level-3.active { + color: var(--accent); +} + +/* Empty state */ +.outline-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + gap: 12px; + color: var(--text-muted); + font-size: 0.85rem; + text-align: center; +} + +/* Reading progress */ +.reading-progress { + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg-sidebar); + flex-shrink: 0; +} + +.reading-progress-bar { + width: 100%; + height: 6px; + background: color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 3px; + overflow: hidden; + margin-bottom: 8px; +} + +.reading-progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 150ms ease; + width: 0%; +} + +.reading-progress-text { + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-align: center; +} + /* Welcome */ .welcome { display: flex; @@ -2128,6 +2350,16 @@ select { word-wrap: break-word; } +/* Mobile right sidebar - hide by default */ +@media (max-width: 768px) { + .right-sidebar { + display: none; + } + .right-sidebar-resize-handle { + display: none; + } +} + /* Mobile help navigation */ @media (max-width: 768px) { .help-nav {