diff --git a/frontend/app.js b/frontend/app.js index 60d22ab..9a5e919 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -4789,6 +4789,412 @@ panel.innerHTML = html; } + // --------------------------------------------------------------------------- + // Find in Page Manager + // --------------------------------------------------------------------------- + const FindInPageManager = { + isOpen: false, + searchTerm: '', + matches: [], + currentIndex: -1, + options: { + caseSensitive: false, + wholeWord: false, + useRegex: false + }, + debounceTimer: null, + previousFocus: null, + + init() { + const bar = document.getElementById('find-in-page-bar'); + const input = document.getElementById('find-input'); + const prevBtn = document.getElementById('find-prev'); + const nextBtn = document.getElementById('find-next'); + const closeBtn = document.getElementById('find-close'); + const caseSensitiveBtn = document.getElementById('find-case-sensitive'); + const wholeWordBtn = document.getElementById('find-whole-word'); + const regexBtn = document.getElementById('find-regex'); + + if (!bar || !input) return; + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Ctrl+F or Cmd+F to open + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); + this.open(); + } + // Escape to close + if (e.key === 'Escape' && this.isOpen) { + e.preventDefault(); + this.close(); + } + // Enter to go to next + if (e.key === 'Enter' && this.isOpen && document.activeElement === input) { + e.preventDefault(); + if (e.shiftKey) { + this.goToPrevious(); + } else { + this.goToNext(); + } + } + // F3 for next/previous + if (e.key === 'F3' && this.isOpen) { + e.preventDefault(); + if (e.shiftKey) { + this.goToPrevious(); + } else { + this.goToNext(); + } + } + }); + + // Input event with debounce + input.addEventListener('input', (e) => { + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.search(e.target.value); + }, 250); + }); + + // Navigation buttons + prevBtn.addEventListener('click', () => this.goToPrevious()); + nextBtn.addEventListener('click', () => this.goToNext()); + + // Close button + closeBtn.addEventListener('click', () => this.close()); + + // Option toggles + caseSensitiveBtn.addEventListener('click', () => { + this.options.caseSensitive = !this.options.caseSensitive; + caseSensitiveBtn.setAttribute('aria-pressed', this.options.caseSensitive); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + wholeWordBtn.addEventListener('click', () => { + this.options.wholeWord = !this.options.wholeWord; + wholeWordBtn.setAttribute('aria-pressed', this.options.wholeWord); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + regexBtn.addEventListener('click', () => { + this.options.useRegex = !this.options.useRegex; + regexBtn.setAttribute('aria-pressed', this.options.useRegex); + this.saveState(); + if (this.searchTerm) this.search(this.searchTerm); + }); + + // Load saved state + this.loadState(); + }, + + open() { + const bar = document.getElementById('find-in-page-bar'); + const input = document.getElementById('find-input'); + if (!bar || !input) return; + + this.previousFocus = document.activeElement; + this.isOpen = true; + bar.hidden = false; + input.focus(); + input.select(); + safeCreateIcons(); + }, + + close() { + const bar = document.getElementById('find-in-page-bar'); + if (!bar) return; + + this.isOpen = false; + bar.hidden = true; + this.clearHighlights(); + this.matches = []; + this.currentIndex = -1; + this.searchTerm = ''; + + // Restore previous focus + if (this.previousFocus && this.previousFocus.focus) { + this.previousFocus.focus(); + } + }, + + search(term) { + this.searchTerm = term; + this.clearHighlights(); + this.hideError(); + + if (!term || term.trim().length === 0) { + this.updateCounter(); + this.updateNavButtons(); + return; + } + + const contentArea = document.querySelector('.md-content'); + if (!contentArea) { + this.updateCounter(); + this.updateNavButtons(); + return; + } + + try { + const regex = this.createRegex(term); + this.matches = []; + this.findMatches(contentArea, regex); + this.currentIndex = this.matches.length > 0 ? 0 : -1; + this.highlightMatches(); + this.updateCounter(); + this.updateNavButtons(); + + if (this.matches.length > 0) { + this.scrollToMatch(0); + } + } catch (err) { + this.showError(err.message); + this.matches = []; + this.currentIndex = -1; + this.updateCounter(); + this.updateNavButtons(); + } + }, + + createRegex(term) { + let pattern = term; + + if (!this.options.useRegex) { + // Escape special regex characters + pattern = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + if (this.options.wholeWord) { + pattern = '\\b' + pattern + '\\b'; + } + + const flags = this.options.caseSensitive ? 'g' : 'gi'; + return new RegExp(pattern, flags); + }, + + findMatches(container, regex) { + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + // Skip code blocks, scripts, styles + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + const tagName = parent.tagName.toLowerCase(); + if (['code', 'pre', 'script', 'style'].includes(tagName)) { + return NodeFilter.FILTER_REJECT; + } + // Skip empty text nodes + if (!node.textContent || node.textContent.trim().length === 0) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + } + ); + + let node; + while (node = walker.nextNode()) { + const text = node.textContent; + let match; + regex.lastIndex = 0; // Reset regex + + while ((match = regex.exec(text)) !== null) { + this.matches.push({ + node: node, + index: match.index, + length: match[0].length, + text: match[0] + }); + + // Prevent infinite loop with zero-width matches + if (match.index === regex.lastIndex) { + regex.lastIndex++; + } + } + } + }, + + highlightMatches() { + this.matches.forEach((match, idx) => { + const node = match.node; + const text = node.textContent; + const before = text.substring(0, match.index); + const matchText = text.substring(match.index, match.index + match.length); + const after = text.substring(match.index + match.length); + + const mark = document.createElement('mark'); + mark.className = idx === this.currentIndex ? 'find-highlight find-highlight-active' : 'find-highlight'; + mark.textContent = matchText; + mark.setAttribute('data-find-index', idx); + + const fragment = document.createDocumentFragment(); + if (before) fragment.appendChild(document.createTextNode(before)); + fragment.appendChild(mark); + if (after) fragment.appendChild(document.createTextNode(after)); + + node.parentNode.replaceChild(fragment, node); + + // Update reference to the mark element + match.element = mark; + }); + }, + + clearHighlights() { + const contentArea = document.querySelector('.md-content'); + if (!contentArea) return; + + const marks = contentArea.querySelectorAll('mark.find-highlight'); + marks.forEach(mark => { + const text = mark.textContent; + const textNode = document.createTextNode(text); + mark.parentNode.replaceChild(textNode, mark); + }); + + // Normalize text nodes to merge adjacent text nodes + contentArea.normalize(); + }, + + goToNext() { + if (this.matches.length === 0) return; + + // Remove active class from current + if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.remove('find-highlight-active'); + } + + // Move to next (with wrapping) + this.currentIndex = (this.currentIndex + 1) % this.matches.length; + + // Add active class to new current + if (this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.add('find-highlight-active'); + } + + this.scrollToMatch(this.currentIndex); + this.updateCounter(); + }, + + goToPrevious() { + if (this.matches.length === 0) return; + + // Remove active class from current + if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.remove('find-highlight-active'); + } + + // Move to previous (with wrapping) + this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1; + + // Add active class to new current + if (this.matches[this.currentIndex].element) { + this.matches[this.currentIndex].element.classList.add('find-highlight-active'); + } + + this.scrollToMatch(this.currentIndex); + this.updateCounter(); + }, + + scrollToMatch(index) { + if (index < 0 || index >= this.matches.length) return; + + const match = this.matches[index]; + if (!match.element) return; + + const contentArea = document.getElementById('content-area'); + if (!contentArea) { + match.element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + + // Calculate position with offset for header + const elementTop = match.element.offsetTop; + const offset = 100; // Offset for header + + contentArea.scrollTo({ + top: elementTop - offset, + behavior: 'smooth' + }); + }, + + updateCounter() { + const counter = document.getElementById('find-counter'); + if (!counter) return; + + const count = this.matches.length; + if (count === 0) { + counter.textContent = '0 occurrence'; + } else if (count === 1) { + counter.textContent = '1 occurrence'; + } else { + counter.textContent = `${count} occurrences`; + } + }, + + updateNavButtons() { + const prevBtn = document.getElementById('find-prev'); + const nextBtn = document.getElementById('find-next'); + if (!prevBtn || !nextBtn) return; + + const hasMatches = this.matches.length > 0; + prevBtn.disabled = !hasMatches; + nextBtn.disabled = !hasMatches; + }, + + showError(message) { + const errorEl = document.getElementById('find-error'); + if (!errorEl) return; + + errorEl.textContent = message; + errorEl.hidden = false; + }, + + hideError() { + const errorEl = document.getElementById('find-error'); + if (!errorEl) return; + + errorEl.hidden = true; + }, + + saveState() { + try { + const state = { + options: this.options + }; + localStorage.setItem('obsigate-find-in-page-state', JSON.stringify(state)); + } catch (e) { + // Ignore localStorage errors + } + }, + + loadState() { + try { + const saved = localStorage.getItem('obsigate-find-in-page-state'); + if (saved) { + const state = JSON.parse(saved); + if (state.options) { + this.options = { ...this.options, ...state.options }; + + // Update button states + const caseSensitiveBtn = document.getElementById('find-case-sensitive'); + const wholeWordBtn = document.getElementById('find-whole-word'); + const regexBtn = document.getElementById('find-regex'); + + if (caseSensitiveBtn) caseSensitiveBtn.setAttribute('aria-pressed', this.options.caseSensitive); + if (wholeWordBtn) wholeWordBtn.setAttribute('aria-pressed', this.options.wholeWord); + if (regexBtn) regexBtn.setAttribute('aria-pressed', this.options.useRegex); + } + } + } catch (e) { + // Ignore localStorage errors + } + } + }; + // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- @@ -4812,6 +5218,7 @@ initLoginForm(); initRecentTab(); RightSidebarManager.init(); + FindInPageManager.init(); // Check auth status first const authOk = await AuthManager.initAuth(); diff --git a/frontend/index.html b/frontend/index.html index 32b4df4..f78330a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -894,6 +894,38 @@ + +
+