cmux/Sources/Find/BrowserFindJavaScript.swift
Yoshiki Agatsuma 76bdf7631a
Add find-in-page (Cmd+F) for browser panels (#837) (#875)
JavaScript-based find using TreeWalker + <mark> highlights with
match counter, next/previous navigation, and drag-to-corner overlay
matching the existing terminal find bar.

- BrowserFindJavaScript: JS generation for search/next/prev/clear
- BrowserSearchOverlay: SwiftUI overlay with IME-safe onSubmit
- BrowserSearchState: Observable state (needle/selected/total)
- TabManager routing: Cmd+F/G dispatches to browser when focused
- Visibility filter: skips script/style/hidden/aria-hidden elements
- Stale DOM guard: isConnected check in next/previous scripts
- Navigation cleanup: clears find on didFinish and didFailNavigation

Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
2026-03-04 16:15:15 -08:00

207 lines
7.9 KiB
Swift

import Foundation
/// JavaScript snippets for find-in-page in WKWebView.
///
/// Uses TreeWalker to scan text nodes and wraps matches with `<mark>` elements.
/// The current match gets an additional `.current` class and is scrolled into view.
enum BrowserFindJavaScript {
// MARK: - Public API
/// Returns JS that highlights all occurrences of `query` in the document body.
/// The script evaluates to a JSON string `{"total":N,"current":0}`.
static func searchScript(query: String) -> String {
let escaped = jsStringEscape(query)
return """
(() => {
const MARK_CLASS = '__cmux-find';
const CURRENT_CLASS = '__cmux-find-current';
// Remove previous highlights first.
\(clearBody)
const query = "\(escaped)";
if (!query) return JSON.stringify({total: 0, current: 0});
const lowerQuery = query.toLowerCase();
const SKIP_TAGS = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','IFRAME','SVG']);
const isVisible = (el) => {
while (el && el !== document.body) {
if (SKIP_TAGS.has(el.tagName)) return false;
if (el.getAttribute('aria-hidden') === 'true') return false;
const st = getComputedStyle(el);
if (st.display === 'none' || st.visibility === 'hidden') return false;
el = el.parentElement;
}
return true;
};
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{ acceptNode(node) { return isVisible(node.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }
);
const matches = [];
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
const text = node.textContent || '';
const lowerText = text.toLowerCase();
let startIndex = 0;
const parts = [];
let lastEnd = 0;
while (true) {
const idx = lowerText.indexOf(lowerQuery, startIndex);
if (idx === -1) break;
parts.push({ start: idx, end: idx + query.length });
startIndex = idx + query.length;
}
if (parts.length === 0) continue;
const parent = node.parentNode;
if (!parent) continue;
const frag = document.createDocumentFragment();
let pos = 0;
for (const part of parts) {
if (part.start > pos) {
frag.appendChild(document.createTextNode(text.substring(pos, part.start)));
}
const mark = document.createElement('mark');
mark.className = MARK_CLASS;
mark.textContent = text.substring(part.start, part.end);
frag.appendChild(mark);
matches.push(mark);
pos = part.end;
}
if (pos < text.length) {
frag.appendChild(document.createTextNode(text.substring(pos)));
}
parent.replaceChild(frag, node);
}
window.__cmuxFindMatches = matches;
window.__cmuxFindIndex = 0;
if (matches.length > 0) {
matches[0].classList.add(CURRENT_CLASS);
matches[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
// Inject highlight styles if not already present.
if (!document.getElementById('__cmux-find-style')) {
const style = document.createElement('style');
style.id = '__cmux-find-style';
style.textContent = `
mark.__cmux-find { background: #facc15; color: #000; border-radius: 2px; }
mark.__cmux-find.__cmux-find-current { background: #f97316; color: #fff; }
`;
document.head.appendChild(style);
}
return JSON.stringify({ total: matches.length, current: 0 });
})()
"""
}
/// Returns JS that moves to the next match. Evaluates to `{"total":N,"current":M}`.
static func nextScript() -> String {
"""
(() => {
const matches = window.__cmuxFindMatches || [];
if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 });
let idx = window.__cmuxFindIndex || 0;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.remove('__cmux-find-current');
idx = (idx + 1) % matches.length;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.add('__cmux-find-current');
matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' });
window.__cmuxFindIndex = idx;
return JSON.stringify({ total: matches.length, current: idx });
})()
"""
}
/// Returns JS that moves to the previous match. Evaluates to `{"total":N,"current":M}`.
static func previousScript() -> String {
"""
(() => {
const matches = window.__cmuxFindMatches || [];
if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 });
let idx = window.__cmuxFindIndex || 0;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.remove('__cmux-find-current');
idx = (idx - 1 + matches.length) % matches.length;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.add('__cmux-find-current');
matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' });
window.__cmuxFindIndex = idx;
return JSON.stringify({ total: matches.length, current: idx });
})()
"""
}
/// Returns JS that removes all find highlights and restores the DOM.
static func clearScript() -> String {
"""
(() => {
\(clearBody)
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
const style = document.getElementById('__cmux-find-style');
if (style) style.remove();
return 'ok';
})()
"""
}
// MARK: - Internal
/// JS snippet (no wrapping IIFE) that removes existing mark highlights.
private static let clearBody = """
document.querySelectorAll('mark.__cmux-find').forEach(mark => {
const parent = mark.parentNode;
if (!parent) return;
const text = document.createTextNode(mark.textContent || '');
parent.replaceChild(text, mark);
parent.normalize();
});
"""
/// Escape a Swift string for safe embedding inside a JS double-quoted string literal.
static func jsStringEscape(_ string: String) -> String {
var result = ""
result.reserveCapacity(string.count)
for scalar in string.unicodeScalars {
switch scalar {
case "\\": result += "\\\\"
case "\"": result += "\\\""
case "\n": result += "\\n"
case "\r": result += "\\r"
case "\t": result += "\\t"
case "\0": result += "\\0"
case "\u{2028}": result += "\\u2028"
case "\u{2029}": result += "\\u2029"
default:
result.append(Character(scalar))
}
}
return result
}
}