From 7ccad9c5897747a92e953a60d0cfbf9593484d3b Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 24 Mar 2026 21:29:05 -0400 Subject: [PATCH] feat: add find-in-page functionality with keyboard shortcuts, regex support, and match navigation --- frontend/app.js | 407 +++++++++++++++++++++++++++++++++++++ frontend/index.html | 32 +++ frontend/style.css | 220 ++++++++++++++++++++ test-find-in-page.html | 442 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1101 insertions(+) create mode 100644 test-find-in-page.html 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 @@ + + + diff --git a/frontend/style.css b/frontend/style.css index b3f4956..ed0bc51 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -3868,3 +3868,223 @@ body.popup-mode .content-area { display: flex; flex-direction: column; align-items: center; padding: 32px 16px; color: var(--text-muted); gap: 8px; font-size: 13px; } + +/* --------------------------------------------------------------------------- + Find in Page + --------------------------------------------------------------------------- */ + +/* Animation d'entrée */ +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Barre principale */ +.find-in-page-bar { + position: fixed; + bottom: 20px; + right: 20px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 16px; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + z-index: 1000; + animation: slideUp 200ms ease; + max-width: calc(100vw - 40px); +} + +.find-in-page-bar[hidden] { + display: none; +} + +/* Contenu de la barre */ +.find-in-page-content { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +/* Icône de recherche */ +.find-icon { + color: var(--text-secondary); + flex-shrink: 0; +} + +/* Input de recherche */ +#find-input { + flex: 1; + min-width: 200px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 0.85rem; + outline: none; + transition: border-color 200ms ease; +} + +#find-input:focus { + border-color: var(--accent); +} + +/* Compteur d'occurrences */ +.find-counter { + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + min-width: 90px; + text-align: center; +} + +/* Boutons de navigation */ +.find-nav-buttons { + display: flex; + gap: 4px; +} + +/* Boutons génériques */ +.find-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; + transition: all 150ms ease; + padding: 0; + flex-shrink: 0; +} + +.find-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-primary)); +} + +.find-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Options */ +.find-options { + display: flex; + gap: 4px; + border-left: 1px solid var(--border); + padding-left: 10px; +} + +.find-option-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + font-weight: 600; +} + +.find-option-btn[aria-pressed="true"] { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 20%, var(--bg-primary)); + color: var(--accent); +} + +.find-option-text { + pointer-events: none; +} + +/* Bouton fermer */ +.find-close { + border-left: 1px solid var(--border); + margin-left: 6px; +} + +.find-close:hover { + background: var(--danger-bg); + border-color: var(--danger); + color: var(--danger); +} + +/* Message d'erreur */ +.find-error { + margin-top: 8px; + padding: 6px 10px; + background: var(--danger-bg); + border: 1px solid var(--danger); + border-radius: 6px; + color: var(--danger); + font-size: 0.75rem; + font-family: 'JetBrains Mono', monospace; +} + +.find-error[hidden] { + display: none; +} + +/* Highlights dans le contenu */ +.find-highlight { + background: rgba(255, 235, 59, 0.4); + padding: 0 2px; + border-radius: 2px; + transition: background 150ms ease; +} + +[data-theme="dark"] .find-highlight { + background: rgba(255, 193, 7, 0.35); +} + +.find-highlight-active { + background: rgba(255, 193, 7, 0.7); + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.3); +} + +[data-theme="dark"] .find-highlight-active { + background: rgba(255, 193, 7, 0.6); + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.4); +} + +/* Responsive mobile */ +@media (max-width: 768px) { + .find-in-page-bar { + bottom: 10px; + left: 10px; + right: 10px; + max-width: none; + padding: 10px 12px; + } + + .find-in-page-content { + gap: 6px; + } + + #find-input { + min-width: 120px; + font-size: 0.8rem; + } + + .find-counter { + font-size: 0.7rem; + min-width: 80px; + } + + .find-btn { + width: 32px; + height: 32px; + } + + .find-options { + padding-left: 6px; + } +} diff --git a/test-find-in-page.html b/test-find-in-page.html new file mode 100644 index 0000000..2b3ebda --- /dev/null +++ b/test-find-in-page.html @@ -0,0 +1,442 @@ + + + + + + Test Find in Page + + + + +
+
+

Test Find in Page - ObsiGate

+
+ +
+
+

Test de la fonctionnalité Find in Page

+ +

Ce document contient du texte pour tester la recherche. Voici quelques mots clés : recherche, test, et fonctionnalité.

+ +

Section 1 : Introduction

+

La recherche dans la page est une fonctionnalité essentielle. Elle permet de trouver rapidement du texte dans un document. La recherche doit être rapide et efficace.

+ +

Section 2 : Fonctionnalités

+
    +
  • Recherche en temps réel avec debounce
  • +
  • Navigation entre les résultats
  • +
  • Support des options : case sensitive, whole word, regex
  • +
  • Compteur d'occurrences
  • +
  • Surlignage des matches
  • +
+ +

Section 3 : Tests

+

Pour tester la recherche, essayez de chercher le mot "recherche" (apparaît plusieurs fois). Vous pouvez aussi tester avec "test" ou "fonctionnalité".

+ +
+

La recherche est un outil puissant pour naviguer dans de longs documents.

+
+ +

Section 4 : Code

+

Voici un exemple de code (qui ne devrait pas être cherché) :

+
function search(term) {
+  console.log('Searching for:', term);
+  return results;
+}
+ +

Section 5 : Plus de contenu

+

Le mot recherche apparaît encore ici. Et encore une fois : recherche. La recherche est partout !

+ + + + + + + + + + + + + + + + + + +
MotOccurrences
rechercheBeaucoup
testPlusieurs
+ +

Section 6 : Conclusion

+

Cette page de test permet de vérifier que la recherche fonctionne correctement dans tous les types de contenu markdown.

+
+
+
+ + + + + + +