Confirming your method works for me.
Relevant thread with my problem:
I had this made based on yours with AI help:
import { App, Modal, TFile, Notice } from 'obsidian';
class SearchModal extends Modal {
private searchInput: HTMLInputElement;
private resultsContainer: HTMLElement;
private results: TFile[] = [];
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.empty();
// Create title
contentEl.createEl('h3', {text: 'Custom Search Obsidian'});
// Create search input
this.searchInput = contentEl.createEl('input', {
type: 'text',
placeholder: 'Enter search text (regex supported without /)...'
});
this.searchInput.style.width = '100%';
this.searchInput.style.marginBottom = '10px';
this.searchInput.style.padding = '8px';
// Search button
const searchButton = contentEl.createEl('button', {
text: 'Search',
cls: 'mod-cta'
});
searchButton.style.width = '100%';
searchButton.style.marginBottom = '10px';
// Results container
this.resultsContainer = contentEl.createEl('div');
this.resultsContainer.style.maxHeight = '60vh';
this.resultsContainer.style.overflow = 'auto';
this.resultsContainer.style.padding = '10px';
// Handle search
const performSearch = async () => {
const searchText = this.searchInput.value;
if (!searchText) {
new Notice('Please enter search text');
return;
}
this.resultsContainer.empty();
this.results = [];
try {
let regex: RegExp;
try {
regex = new RegExp(searchText, 'i');
} catch (error) {
new Notice('Invalid regex pattern');
return;
}
await this.searchVault(regex);
} catch (error) {
new Notice('Search failed: ' + error.message);
console.error('Search error:', error);
}
};
// Add event listeners
searchButton.onclick = performSearch;
this.searchInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
await performSearch();
}
});
// Focus input
this.searchInput.focus();
}
private async searchVault(regex: RegExp) {
const startTime = performance.now();
const files = this.app.vault.getMarkdownFiles();
const results: Array<{file: TFile, content: string, matches: Array<{text: string, lineNumber: number, originalLine: string}>}> = [];
for (const file of files) {
try {
const content = await this.app.vault.cachedRead(file);
const lines = content.split('\n');
const matches: Array<{text: string, lineNumber: number, originalLine: string}> = [];
lines.forEach((line, index) => {
if (regex.test(line)) {
const highlighted = this.highlightMatches(line.trim(), regex);
matches.push({
text: highlighted,
lineNumber: index,
originalLine: line
});
}
});
if (matches.length > 0) {
results.push({
file,
content,
matches
});
}
} catch (error) {
console.error(`Error reading ${file.path}:`, error);
}
}
// Sort results by filename
results.sort((a, b) => a.file.basename.localeCompare(b.file.basename));
const endTime = performance.now();
const searchTime = ((endTime - startTime) / 1000).toFixed(2);
if (results.length === 0) {
this.resultsContainer.createEl('div', {
text: 'No results found'
});
return;
}
new Notice(`Found matches in ${results.length} files (${searchTime}s)`);
// Display results
for (const result of results) {
const resultDiv = this.resultsContainer.createEl('div', { cls: 'search-result' });
resultDiv.style.marginBottom = '15px';
resultDiv.style.padding = '10px';
resultDiv.style.borderBottom = '1px solid var(--background-modifier-border)';
// Create file link
const fileLink = resultDiv.createEl('div', { cls: 'search-result-file' });
fileLink.style.marginBottom = '5px';
fileLink.style.fontWeight = 'bold';
fileLink.style.cursor = 'pointer';
fileLink.textContent = result.file.basename;
// Add click handler for file (goes to first match)
fileLink.addEventListener('click', async () => {
if (result.matches.length > 0) {
await this.navigateToMatch(result.file, result.matches[0].lineNumber);
} else {
await this.app.workspace.activeLeaf.openFile(result.file);
}
this.close();
});
// Add matches
const matchesContainer = resultDiv.createEl('div');
for (const match of result.matches) {
const matchDiv = matchesContainer.createEl('div');
matchDiv.style.marginLeft = '10px';
matchDiv.style.marginTop = '5px';
matchDiv.style.cursor = 'pointer';
// Use innerHTML for highlighted text
matchDiv.innerHTML = match.text;
// Add click handler for specific match
matchDiv.addEventListener('click', async () => {
await this.navigateToMatch(result.file, match.lineNumber);
this.close();
});
}
// Add copy button
const copyButton = resultDiv.createEl('button', {
text: 'Copy',
cls: 'mod-cta'
});
copyButton.style.marginTop = '5px';
copyButton.addEventListener('click', () => {
const textToCopy = `[[${result.file.basename}]] | ${result.matches.map(m => m.originalLine.trim()).join('\n')}`;
navigator.clipboard.writeText(textToCopy);
new Notice('Copied to clipboard');
});
}
}
private highlightMatches(text: string, regex: RegExp): string {
try {
const escaped = text.replace(/[&<>'"]/g, char => ({
'&': '&',
'<': '<',
'>': '>',
"'": ''',
'"': '"'
}[char] || char));
return escaped.replace(regex, match =>
`<span style="background-color: rgba(255, 165, 0, 0.5);">${match}</span>`
);
} catch (error) {
console.error('Error highlighting matches:', error);
return text;
}
}
private async navigateToMatch(file: TFile, lineNumber: number) {
try {
// Open the file
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
// Wait for the file to fully load and the editor to be ready
await new Promise(resolve => setTimeout(resolve, 150));
// Get the editor from the active view
const activeView = this.app.workspace.getActiveViewOfType(require('obsidian').MarkdownView);
if (activeView && activeView.editor) {
const editor = activeView.editor;
// Wait longer to override Remember Cursor Position plugin
await new Promise(resolve => setTimeout(resolve, 400));
// Set cursor position to the specific line multiple times to ensure it sticks
const pos = { line: lineNumber, ch: 0 };
editor.setCursor(pos);
// Additional delay and re-set cursor to make sure it overrides other plugins
await new Promise(resolve => setTimeout(resolve, 50));
editor.setCursor(pos);
// Scroll to make the line visible
editor.scrollIntoView({ from: pos, to: pos }, true);
// Focus the editor and set cursor one more time
editor.focus();
editor.setCursor(pos);
console.log(`Navigated to line ${lineNumber + 1} in ${file.basename}`);
} else {
console.error('Could not get editor instance');
new Notice('Could not navigate to line - editor not available');
}
} catch (error) {
console.error('Error navigating to match:', error);
new Notice('Error navigating to match: ' + error.message);
}
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
export class CustomVaultSearch {
constructor(private app: App) {}
async onload() {
this.addCommand({
id: 'custom-vault-search',
name: 'Custom Vault Search',
callback: () => this.openSearchModal()
});
}
private async openSearchModal() {
new SearchModal(this.app).open();
}
}
export async function invoke(app: App): Promise<void> {
new SearchModal(app).open();
}
This script has results in 4-5 seconds like Backlinks’ Unlinked Mentions I was talking about in the other thread. (First search is slow but any consecutive searcher are fast.)
So obviously something is wrong with the mobile app’s built in search element…
Thanks for the heads-up @Yurcee!