--- title: "Custom Ctrl f For Embedded Content" slug: custom-search-for-embedded-content date: 2025-10-11 updated: 2025-10-11 tags: - code description: "How to intercept the browser's native find dialog and implement scoped search within" status: published --- *How to intercept the browser's native find dialog and implement scoped search within an embedded document viewer.* --- ## The Problem Our app displays candidate resumes inside a modal. The resume is rendered either as a PDF (via pdf.js) or as parsed HTML inside a container. The problem: when a recruiter presses Ctrl+F to search for a keyword in the resume, the browser's native find dialog opens and searches the **entire page** — including navigation, sidebars, other tabs, and background content. This is terrible UX. The recruiter wants to find "Python" in the resume, not in the sidebar menu or in a hidden tab's content. --- ## The Solution: Intercept and Replace We need to: 1. Intercept Ctrl+F when the resume tab is active 2. Prevent the native find dialog from opening 3. Show a custom search bar scoped to the resume content 4. Highlight matches within the PDF/HTML text layer 5. Support Ctrl+G / Enter for "find next" navigation 6. Handle accented characters (Résumé → Resume should match) --- ## Implementation ### Step 1: Intercept Ctrl+F The key insight: `event.preventDefault()` on the `keydown` event stops the native find dialog, but only if you catch it before the browser processes it. ```javascript var handleKeydown = function(event) { if ((event.ctrlKey || event.metaKey) && event.key === "f") { // Only intercept when resume tab is active if (isResumeTabActive()) { event.preventDefault(); event.stopPropagation(); // Focus our custom search input var input = document.getElementById("resume-search-input"); if (input) { input.focus(); input.select(); } } } }; document.addEventListener("keydown", handleKeydown); ``` Important: use `event.key === "f"` rather than checking `keyCode`. It handles keyboard layouts correctly and is the modern standard. ### Step 2: The Search Bar UI ```html ``` ### Step 3: Finding Matches in the Text Layer PDF.js renders text in a `
` with individual `` elements for each text segment. HTML resumes use a similar structure. We search within these spans: ```javascript function findAndHighlightMatches(searchTerm) { var matches = []; var normalizedSearch = normalizeText(searchTerm); if (normalizedSearch.length === 0) return matches; // Target both PDF text layer and HTML resume container var textSpans = document.querySelectorAll( "#pdfjs-container .textLayer span:not(.highlight), " + "#resume-html-container .textLayer span:not(.highlight)" ); textSpans.forEach(function(span) { var text = span.textContent; var normalizedText = normalizeText(text); if (normalizedText.indexOf(normalizedSearch) === -1) { return; // Skip spans without matches } // Store original content for later restoration if (!originalContents.has(span)) { originalContents.set(span, span.innerHTML); } // Find all positions of the search term var matchPositions = []; var idx = 0; while ((idx = normalizedText.indexOf(normalizedSearch, idx)) !== -1) { matchPositions.push({ start: idx, end: idx + normalizedSearch.length }); idx += normalizedSearch.length; } // Build highlighted HTML var result = ""; var lastEnd = 0; matchPositions.forEach(function(pos) { var origStart = mapNormalizedToOriginal(text, pos.start); var origEnd = mapNormalizedToOriginal(text, pos.end); result += escapeHtml(text.substring(lastEnd, origStart)); result += "" + escapeHtml(text.substring(origStart, origEnd)) + ""; matches.push({ element: span, matchIndex: matches.length }); lastEnd = origEnd; }); result += escapeHtml(text.substring(lastEnd)); span.innerHTML = result; }); return matches; } ``` ### Step 4: Accent-Insensitive Search Resumes often contain accented characters (especially names). "José" should match when you search for "Jose". We use Unicode NFD normalization: ```javascript function normalizeText(text) { return text.normalize("NFD") .replace(/[\u0300-\u036f]/g, "") // Strip combining marks .toLowerCase(); } ``` But here's the tricky part: when we normalize text for searching, the character positions shift. "Résumé" normalizes to "resume" but the accent characters occupy positions in the original string. We need a mapping function: ```javascript function mapNormalizedToOriginal(originalText, normalizedPos) { var origIndex = 0; var normIndex = 0; while (normIndex < normalizedPos && origIndex < originalText.length) { var char = originalText[origIndex]; var normalized = char.normalize("NFD") .replace(/[\u0300-\u036f]/g, ""); if (normalized.length === 0) { // This is a combining mark — skip in original, don't advance // normalized position origIndex++; } else { normIndex++; origIndex++; } } return origIndex; } ``` This walks through both the original and normalized text in lockstep, correctly mapping positions even when accented characters expand or contract. ### Step 5: Match Navigation (Ctrl+G / Enter) ```javascript // Ctrl+G for next match (mirrors Chrome's behavior) var handleCtrlG = function(event) { if ((event.ctrlKey || event.metaKey) && event.key === "g") { if (isResumeTabActive()) { event.preventDefault(); event.stopPropagation(); var input = document.getElementById("resume-search-input"); if (!input.value) { input.focus(); input.select(); } else { goToNextMatch(); } } } }; // Enter/Shift+Enter on the search input var handleSearchKeydown = function(event) { if (event.key === "Enter" && isSearchInputFocused()) { event.preventDefault(); if (event.shiftKey) { goToPrevMatch(); } else { goToNextMatch(); } } }; ``` ### Step 6: Highlighting the Current Match ```javascript function highlightCurrentMatch(index, skipScroll) { // Remove "current" styling from all highlights document.querySelectorAll(".resume-search-highlight") .forEach(function(el) { el.classList.remove("current-match"); }); if (matches[index]) { var span = matches[index].element; var highlights = span.querySelectorAll(".resume-search-highlight"); // Count how many matches in this span come before ours var matchIndexInSpan = 0; for (var i = 0; i < index; i++) { if (matches[i].element === span) matchIndexInSpan++; } var target = highlights[matchIndexInSpan]; if (target) { target.classList.add("current-match"); if (!skipScroll) { target.scrollIntoView({ behavior: "smooth", block: "center" }); } } } } ``` ### Step 7: Cleanup When the search is cleared or the user switches to a different candidate, we restore original content: ```javascript function clearHighlights() { originalContents.forEach(function(originalHTML, span) { if (span && span.parentNode) { span.innerHTML = originalHTML; } }); originalContents.clear(); } // Clear when candidate changes $scope.$watch("candidate.id", function(newVal, oldVal) { if (newVal !== oldVal) { clearSearch(); } }); // Remove event listeners on destroy $scope.$on("$destroy", function() { document.removeEventListener("keydown", handleKeydown); document.removeEventListener("keydown", handleCtrlG); clearHighlights(); }); ``` --- ## The CSS ```css .resume-search-highlight { background-color: #fff3a8; border-radius: 2px; padding: 0 1px; } .resume-search-highlight.current-match { background-color: #ff9632; box-shadow: 0 0 3px rgba(255, 150, 50, 0.5); } .resume-search-bar { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #f8f9fa; border-bottom: 1px solid #e0e0e0; } ``` --- ## Edge Cases and Gotchas ### 1. Multi-page PDFs PDF.js renders each page in its own `textLayer` div. Our `querySelectorAll` naturally handles this since it selects all spans across all pages. The scroll-into-view handles cross-page navigation. ### 2. Performance on Large Resumes For a 10-page resume with thousands of text spans, searching on every keystroke can be sluggish. Solution: debounce the search by 150ms: ```javascript var searchTimeout = null; $scope.onSearchTextChange = function() { if (searchTimeout) clearTimeout(searchTimeout); searchTimeout = setTimeout(function() { performSearch($scope.searchText); }, 150); }; ``` ### 3. Special Characters in Search If the user searches for "C++", the `+` shouldn't be treated as regex. Since we use `indexOf` rather than regex, this is handled naturally. ### 4. Restoring Content After innerHTML Manipulation When we modify `span.innerHTML` to inject highlight `` elements, we lose any event listeners on those spans. Since PDF.js text layer spans are purely presentational (no interactivity), this is fine. But if your content has interactive elements, you'd need to use Range/Selection APIs instead. --- ## Takeaways 1. **`event.preventDefault()` on keydown stops native Ctrl+F** — but only if you're listening at the right level and the event hasn't already been consumed. 2. **Unicode normalization is essential** for any search that deals with real-world text. NFD decomposition + stripping combining marks gives you accent-insensitive search in 2 lines. 3. **Position mapping between normalized and original text** is the key algorithmic challenge. Walk both strings in parallel. 4. **Store original content before modifying DOM.** A Map of element -> original innerHTML makes cleanup trivial. 5. **scrollIntoView with `block: "center"`** provides the best UX for match navigation — the user sees context above and below the match. This feature shipped as two revisions: the initial Ctrl+F implementation, and a follow-up adding Ctrl+G navigation. Total code: ~320 lines of JavaScript, ~30 lines of CSS, and an HTML search bar template.