feat: add find-in-page functionality with keyboard shortcuts, regex support, and match navigation
This commit is contained in:
parent
94deb08e16
commit
7ccad9c589
407
frontend/app.js
407
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();
|
||||
|
||||
@ -894,6 +894,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Find in Page Bar -->
|
||||
<div class="find-in-page-bar" id="find-in-page-bar" hidden>
|
||||
<div class="find-in-page-content">
|
||||
<i data-lucide="search" class="find-icon"></i>
|
||||
<input type="text" id="find-input" placeholder="Rechercher..." autocomplete="off" aria-label="Rechercher dans la page">
|
||||
<span class="find-counter" id="find-counter" aria-live="polite">0 occurrence</span>
|
||||
<div class="find-nav-buttons">
|
||||
<button class="find-btn" id="find-prev" title="Précédent (Shift+Enter)" aria-label="Résultat précédent" disabled>
|
||||
<i data-lucide="chevron-up" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="find-btn" id="find-next" title="Suivant (Enter)" aria-label="Résultat suivant" disabled>
|
||||
<i data-lucide="chevron-down" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="find-options">
|
||||
<button class="find-btn find-option-btn" id="find-case-sensitive" title="Respecter la casse" aria-label="Respecter la casse" aria-pressed="false">
|
||||
<span class="find-option-text">Aa</span>
|
||||
</button>
|
||||
<button class="find-btn find-option-btn" id="find-whole-word" title="Mot entier" aria-label="Mot entier" aria-pressed="false">
|
||||
<span class="find-option-text">abc</span>
|
||||
</button>
|
||||
<button class="find-btn find-option-btn" id="find-regex" title="Expression régulière" aria-label="Expression régulière" aria-pressed="false">
|
||||
<span class="find-option-text">.*</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="find-btn find-close" id="find-close" title="Fermer (Escape)" aria-label="Fermer la recherche">
|
||||
<i data-lucide="x" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="find-error" id="find-error" hidden></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
442
test-find-in-page.html
Normal file
442
test-find-in-page.html
Normal file
@ -0,0 +1,442 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Find in Page</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header class="header">
|
||||
<h1>Test Find in Page - ObsiGate</h1>
|
||||
</header>
|
||||
|
||||
<main class="content-area" id="content-area" style="padding: 40px;">
|
||||
<div class="md-content">
|
||||
<h1>Test de la fonctionnalité Find in Page</h1>
|
||||
|
||||
<p>Ce document contient du texte pour tester la recherche. Voici quelques mots clés : <strong>recherche</strong>, <strong>test</strong>, et <strong>fonctionnalité</strong>.</p>
|
||||
|
||||
<h2>Section 1 : Introduction</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Section 2 : Fonctionnalités</h2>
|
||||
<ul>
|
||||
<li>Recherche en temps réel avec debounce</li>
|
||||
<li>Navigation entre les résultats</li>
|
||||
<li>Support des options : case sensitive, whole word, regex</li>
|
||||
<li>Compteur d'occurrences</li>
|
||||
<li>Surlignage des matches</li>
|
||||
</ul>
|
||||
|
||||
<h2>Section 3 : Tests</h2>
|
||||
<p>Pour tester la recherche, essayez de chercher le mot "recherche" (apparaît plusieurs fois). Vous pouvez aussi tester avec "test" ou "fonctionnalité".</p>
|
||||
|
||||
<blockquote>
|
||||
<p>La recherche est un outil puissant pour naviguer dans de longs documents.</p>
|
||||
</blockquote>
|
||||
|
||||
<h2>Section 4 : Code</h2>
|
||||
<p>Voici un exemple de code (qui ne devrait pas être cherché) :</p>
|
||||
<pre><code>function search(term) {
|
||||
console.log('Searching for:', term);
|
||||
return results;
|
||||
}</code></pre>
|
||||
|
||||
<h2>Section 5 : Plus de contenu</h2>
|
||||
<p>Le mot recherche apparaît encore ici. Et encore une fois : recherche. La recherche est partout !</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mot</th>
|
||||
<th>Occurrences</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>recherche</td>
|
||||
<td>Beaucoup</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>test</td>
|
||||
<td>Plusieurs</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Section 6 : Conclusion</h2>
|
||||
<p>Cette page de test permet de vérifier que la recherche fonctionne correctement dans tous les types de contenu markdown.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Find in Page Bar -->
|
||||
<div class="find-in-page-bar" id="find-in-page-bar" hidden>
|
||||
<div class="find-in-page-content">
|
||||
<i data-lucide="search" class="find-icon"></i>
|
||||
<input type="text" id="find-input" placeholder="Rechercher..." autocomplete="off" aria-label="Rechercher dans la page">
|
||||
<span class="find-counter" id="find-counter" aria-live="polite">0 occurrence</span>
|
||||
<div class="find-nav-buttons">
|
||||
<button class="find-btn" id="find-prev" title="Précédent (Shift+Enter)" aria-label="Résultat précédent" disabled>
|
||||
<i data-lucide="chevron-up" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="find-btn" id="find-next" title="Suivant (Enter)" aria-label="Résultat suivant" disabled>
|
||||
<i data-lucide="chevron-down" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="find-options">
|
||||
<button class="find-btn find-option-btn" id="find-case-sensitive" title="Respecter la casse" aria-label="Respecter la casse" aria-pressed="false">
|
||||
<span class="find-option-text">Aa</span>
|
||||
</button>
|
||||
<button class="find-btn find-option-btn" id="find-whole-word" title="Mot entier" aria-label="Mot entier" aria-pressed="false">
|
||||
<span class="find-option-text">abc</span>
|
||||
</button>
|
||||
<button class="find-btn find-option-btn" id="find-regex" title="Expression régulière" aria-label="Expression régulière" aria-pressed="false">
|
||||
<span class="find-option-text">.*</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="find-btn find-close" id="find-close" title="Fermer (Escape)" aria-label="Fermer la recherche">
|
||||
<i data-lucide="x" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="find-error" id="find-error" hidden></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simplified FindInPageManager for testing
|
||||
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;
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
this.open();
|
||||
}
|
||||
if (e.key === 'Escape' && this.isOpen) {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
if (e.key === 'Enter' && this.isOpen && document.activeElement === input) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
this.goToPrevious();
|
||||
} else {
|
||||
this.goToNext();
|
||||
}
|
||||
}
|
||||
if (e.key === 'F3' && this.isOpen) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
this.goToPrevious();
|
||||
} else {
|
||||
this.goToNext();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.search(e.target.value);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
prevBtn.addEventListener('click', () => this.goToPrevious());
|
||||
nextBtn.addEventListener('click', () => this.goToNext());
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
|
||||
caseSensitiveBtn.addEventListener('click', () => {
|
||||
this.options.caseSensitive = !this.options.caseSensitive;
|
||||
caseSensitiveBtn.setAttribute('aria-pressed', this.options.caseSensitive);
|
||||
if (this.searchTerm) this.search(this.searchTerm);
|
||||
});
|
||||
|
||||
wholeWordBtn.addEventListener('click', () => {
|
||||
this.options.wholeWord = !this.options.wholeWord;
|
||||
wholeWordBtn.setAttribute('aria-pressed', this.options.wholeWord);
|
||||
if (this.searchTerm) this.search(this.searchTerm);
|
||||
});
|
||||
|
||||
regexBtn.addEventListener('click', () => {
|
||||
this.options.useRegex = !this.options.useRegex;
|
||||
regexBtn.setAttribute('aria-pressed', this.options.useRegex);
|
||||
if (this.searchTerm) this.search(this.searchTerm);
|
||||
});
|
||||
},
|
||||
|
||||
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();
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
},
|
||||
|
||||
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 = '';
|
||||
|
||||
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) {
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
this.matches.push({
|
||||
node: node,
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
text: match[0]
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
contentArea.normalize();
|
||||
},
|
||||
|
||||
goToNext() {
|
||||
if (this.matches.length === 0) return;
|
||||
|
||||
if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
|
||||
this.matches[this.currentIndex].element.classList.remove('find-highlight-active');
|
||||
}
|
||||
|
||||
this.currentIndex = (this.currentIndex + 1) % this.matches.length;
|
||||
|
||||
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;
|
||||
|
||||
if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
|
||||
this.matches[this.currentIndex].element.classList.remove('find-highlight-active');
|
||||
}
|
||||
|
||||
this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1;
|
||||
|
||||
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;
|
||||
|
||||
match.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
FindInPageManager.init();
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user