Custom Ctrl f For Embedded Content
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:
- Intercept Ctrl+F when the resume tab is active
- Prevent the native find dialog from opening
- Show a custom search bar scoped to the resume content
- Highlight matches within the PDF/HTML text layer
- Support Ctrl+G / Enter for “find next” navigation
- 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.
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
<div class="resume-search-bar" ng-show="searchBarVisible">
<input id="resume-search-input"
type="text"
ng-model="searchText"
ng-change="onSearchTextChange()"
placeholder="Find in resume..." />
<span class="match-count">
{{ currentMatchIndex }} / {{ totalMatches }}
</span>
<button ng-click="goToPrevMatch()">▲</button>
<button ng-click="goToNextMatch()">▼</button>
<button ng-click="clearSearch()">✕</button>
</div>
Step 3: Finding Matches in the Text Layer
PDF.js renders text in a <div class="textLayer"> with individual <span> elements for each text segment. HTML resumes use a similar structure. We search within these spans:
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 += "<span class='resume-search-highlight'>" +
escapeHtml(text.substring(origStart, origEnd)) +
"</span>";
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:
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:
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)
// 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
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:
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
.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:
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 <span> 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
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.- 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.
- Position mapping between normalized and original text is the key algorithmic challenge. Walk both strings in parallel.
- Store original content before modifying DOM. A Map of element -> original innerHTML makes cleanup trivial.
- 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.