Regex Replace and Search Plugin for Obsidian written in Vanilla.js

Regex Replace Plugin for Obsidian

Overview

Regex Replace is a plugin that allows you to run regex-based search and replace operations directly inside the editor.
It comes with a convenient modal dialog, live preview of matches, and a preset system to save, overwrite, and delete commonly used regex operations.

Written in vanilla JavaScript :blush:


Installation

  1. Download the plugin files (manifest.json and main.js).

  2. Copy them into a folder inside your Obsidian vault, for example:

    <your-vault>/.obsidian/plugins/regex-replace/
    

    The folder should contain:

    manifest.json
    main.js
    
  3. Restart or reload Obsidian.

  4. Go to Settings → Community Plugins and enable Regex Replace.


Usage

You can open the plugin in two ways:

  • Command Palette (Ctrl+P / Cmd+P) → Search for Regex Search & Replace
  • Right-click context menu inside the editor → Regex Search & Replace

This will open the Regex Search & Replace Modal.


Features

1. Regex Input Fields

  • Regex pattern – enter the search pattern (JavaScript regex syntax).
  • Replacement text – enter the replacement string ($1, $2 etc. for capturing groups).
  • Flags – e.g. g (global), i (case-insensitive), m (multiline).

2. Options

  • Use only selection – restrict replacement to the current text selection.
  • Replace all – toggle between replacing all matches or just the first one.

3. Live Preview

  • Shows the first 50 matches in a scrollable table.
  • Each row displays the full line before and after replacement.
  • Matches are highlighted inline for better context.

4. Execute

  • Applies the regex replacement to either the current selection or the entire document.
  • Updates the editor content immediately.

Presets

Presets let you save, reuse, and manage regex operations.

Saving a Preset

  1. Configure your regex pattern, replacement, and options.
  2. Click “Save as preset”.
  3. Enter a preset name.
  4. The preset will appear in the dropdown.
  • If a preset with the same name already exists, it will be overwritten.
  • You will see a notice: “Preset overwritten: NAME”.

Loading a Preset

  1. Select a preset from the dropdown.
  2. The input fields (pattern, replacement, flags, etc.) will automatically update.
  3. Preview and Execute now use these loaded values.

Deleting a Preset

  1. Select the preset in the dropdown.
  2. Click “Delete preset”.
  3. The preset will be removed permanently.

Examples

Example 1: Swap First and Last Names

Text

Wisniewski Frank
Adenau
Wisniewski Frank
Adenau

Search pattern

^(\w+) (\w+)$\n^(\w+)

Replacement text

$2, $1, 53518 $3

Result

Frank, Wisniewski, 53518 Adenau
Frank, Wisniewski, 53518 Adenau

Example 2: Replace All i With x

Text

This is a simple line
With multiple words

Search pattern

i

Replacement text

x

Result

Thxs xs a sxmple lxne
Wxth multxple words

Tips

  • Use ^ and $ for line anchors.

  • Use capturing groups ( … ) with $1, $2 in the replacement.

  • Combine flags:

    • g = global (all matches)
    • i = case-insensitive
    • m = multiline (anchors match at line breaks)

Known Limitations

  • Very large documents may slow down preview if many matches are found (preview is capped at 50 matches).
  • Regex syntax follows JavaScript’s RegExp engine – not all features from PCRE or other regex engines are supported.

Files

manifest.json

{
  "id": "regex-replace",
  "name": "Regex Replace",
  "version": "1.0.0",
  "minAppVersion": "0.15.0",
  "description": "Führt Regex-Suchen und Ersetzen im aktiven Editor aus.",
  "author": "Frank Wisniewski",
  "isDesktopOnly": false
}

main.js

const { Plugin, Modal, Setting, Notice } = require("obsidian");

module.exports = class RegexReplacePlugin extends Plugin {
	async onload() {
		this.settings = Object.assign(
			{ 
				pattern: "", replacement: "", flags: "gm", 
				onlySelection: false, replaceAll: true,
				presets: []   
			}, 
			await this.loadData()
		);

		this.addCommand({
			id: "regex-search-replace",
			name: "Regex Search & Replace",
			editorCallback: (editor) => this.openModal(editor),
		});

		this.registerEvent(
			this.app.workspace.on("editor-menu", (menu, editor) =>
				menu.addItem((item) =>
					item.setTitle("Regex Search & Replace").setIcon("search").onClick(() => this.openModal(editor))
				)
			)
		);
	}

	openModal(editor) {
		new RegexReplaceModal(
			this.app,
			editor,
			this.settings,
			async (pattern, replacement, flags, onlySelection, replaceAll) => {
				try {
					let regexFlags = (flags || "").toString();
					if (!replaceAll) regexFlags = regexFlags.replace("g", "");
					const regex = new RegExp(pattern, regexFlags);

					if (onlySelection) {
						const sel = editor.getSelection();
						if (!sel) return new Notice("No selection found.");
						editor.replaceSelection(sel.replace(regex, replacement));
					} else {
						editor.setValue(editor.getValue().replace(regex, replacement));
					}

					this.settings = { ...this.settings, pattern, replacement, flags, onlySelection, replaceAll };
					await this.saveData(this.settings);
					new Notice("Regex Replace successful!");
				} catch (err) {
					new Notice("Regex error: " + err);
				}
			},
			this
		).open();
	}
};

// --- Modal for entering preset name ---
class PresetNameModal extends Modal {
	constructor(app, onSubmit) {
		super(app);
		this.onSubmit = onSubmit;
	}

	onOpen() {
		const { contentEl } = this;
		contentEl.createEl("h2", { text: "Save Preset" });

		let name = "";

		new Setting(contentEl)
			.setName("Preset name")
			.addText((t) => t.setPlaceholder("e.g. Swap Names").onChange((val) => (name = val)));

		new Setting(contentEl)
			.addButton((b) => b.setButtonText("Save").setCta().onClick(() => {
				if (name) {
					this.close();
					this.onSubmit(name);
				}
			}))
			.addButton((b) => b.setButtonText("Cancel").onClick(() => this.close()));
	}

	onClose() {
		this.contentEl.empty();
	}
}

// --- Main Modal ---
class RegexReplaceModal extends Modal {
	constructor(app, editor, settings, onSubmit, plugin) {
		super(app);
		Object.assign(this, { editor, settings, onSubmit, plugin });

		// state from settings
		this.pattern = settings.pattern;
		this.replacement = settings.replacement;
		this.flags = settings.flags;
		this.onlySelection = settings.onlySelection;
		this.replaceAll = settings.replaceAll;

		this.controls = {};
	}

	// central method: load preset into variables + UI
	loadPreset(p) {
		this.pattern = p.pattern;
		this.replacement = p.replacement;
		this.flags = p.flags;
		this.onlySelection = p.onlySelection;
		this.replaceAll = p.replaceAll;

		if (this.controls.pattern) this.controls.pattern.setValue(this.pattern);
		if (this.controls.replacement) this.controls.replacement.setValue(this.replacement);
		if (this.controls.flags) this.controls.flags.setValue(this.flags);
		if (this.controls.onlySelection) this.controls.onlySelection.setValue(this.onlySelection);
		if (this.controls.replaceAll) this.controls.replaceAll.setValue(this.replaceAll);
	}

	showPreview(previewEl) {
		previewEl.empty();
		let flags = this.flags;
		if (!flags.includes("g")) flags += "g";

		let regex;
		try {
			regex = new RegExp(this.pattern, flags);
		} catch (e) {
			return previewEl.createEl("div", { text: "Invalid regex: " + e.message });
		}

		const text = this.onlySelection ? this.editor.getSelection() : this.editor.getValue();

		const wrapper = previewEl.createEl("div", {
			attr: { style: "max-height:200px; overflow-y:auto; width:100%;" }
		});

		const table = wrapper.createEl("table", {
			attr: { style: "width:100%; min-width:100%; border-collapse:collapse; font-family:var(--font-monospace); font-size:0.9em;" }
		});

		const thead = table.createEl("thead", { attr: { style: "position:sticky; top:0; background:var(--background-secondary);" }});
		const headerRow = thead.createEl("tr");
		["Before", "After"].forEach(h =>
			headerRow.createEl("th", {
				text: h,
				attr: { style: "border:1px solid var(--background-modifier-border); padding:4px 6px; text-align:left;" }
			})
		);

		const tbody = table.createEl("tbody");
		let count = 0;

		text.replace(regex, (...args) => {
			if (count >= 50) return;

			const match = args[0];

			const startLine = text.lastIndexOf("\n", args[args.length - 2]);
			const endLine = text.indexOf("\n", args[args.length - 2] + match.length);
			const line = text.slice(startLine + 1, endLine === -1 ? undefined : endLine);

			const replacedLine = line.replace(regex, this.replacement);

			const row = tbody.createEl("tr");
			row.createEl("td", {
				text: line,
				attr: { style: "border:1px solid var(--background-modifier-border); padding:4px 6px; white-space:pre-wrap; width:50%;" }
			});
			row.createEl("td", {
				text: replacedLine,
				attr: { style: "border:1px solid var(--background-modifier-border); padding:4px 6px; white-space:pre-wrap; width:50%;" }
			});

			count++;
			return match;
		});

		previewEl.createEl("div", {
			text: count ? `${count} matches (max. 50 shown)` : "No matches found.",
			attr: { style: "margin-top:4px; font-size:0.85em; color:var(--text-muted);" }
		});
	}

	onOpen() {
		const { contentEl } = this;
		contentEl.createEl("h2", { text: "Regex Search & Replace" });

		const previewEl = contentEl.createDiv({ cls: "regex-preview" });

		let dropdown;

		// Preset Dropdown
		const presetSetting = new Setting(contentEl).setName("Saved presets");
		presetSetting.addDropdown(dd => {
			dropdown = dd;
			dd.addOption("", "-- Select preset --");
			this.settings.presets.forEach(p => dd.addOption(p.name, p.name));
			dd.onChange((val) => {
				const p = this.settings.presets.find(pr => pr.name === val);
				if (p) {
					this.loadPreset(p);
					new Notice("Preset loaded: " + val);
				}
			});
		});

		// Buttons for presets
		new Setting(contentEl)
			.addButton(b => b.setButtonText("Save as preset").onClick(() => {
				new PresetNameModal(this.app, async (name) => {
					const exists = this.settings.presets.some(p => p.name === name);
					this.settings.presets = this.settings.presets.filter(p => p.name !== name);

					const newPreset = {
						name,
						pattern: this.pattern,
						replacement: this.replacement,
						flags: this.flags,
						onlySelection: this.onlySelection,
						replaceAll: this.replaceAll
					};
					this.settings.presets.push(newPreset);
					await this.plugin.saveData(this.settings);

					if (dropdown) {
						// clear & rebuild to avoid duplicate options
						dropdown.selectEl.innerHTML = "";
						dropdown.addOption("", "-- Select preset --");
						this.settings.presets.forEach(p => dropdown.addOption(p.name, p.name));
						dropdown.setValue(name);
					}

					new Notice(exists ? `Preset overwritten: ${name}` : `Preset saved: ${name}`);
				}).open();
			}))
			.addButton(b => b.setButtonText("Delete preset").onClick(async () => {
				const current = dropdown.getValue();
				if (!current) {
					new Notice("No preset selected to delete.");
					return;
				}
				this.settings.presets = this.settings.presets.filter(p => p.name !== current);
				await this.plugin.saveData(this.settings);

				// refresh dropdown
				dropdown.selectEl.innerHTML = "";
				dropdown.addOption("", "-- Select preset --");
				this.settings.presets.forEach(p => dropdown.addOption(p.name, p.name));
				dropdown.setValue("");

				new Notice(`Preset deleted: ${current}`);
			}));

		// Input fields and toggles
		const fields = [
			{ key: "pattern", label: "Regex pattern", type: "text", val: this.pattern, set: (v) => (this.pattern = v) },
			{ key: "replacement", label: "Replacement text", type: "text", val: this.replacement, set: (v) => (this.replacement = v) },
			{ key: "flags", label: "Flags (e.g. g, i, m)", type: "text", val: this.flags, set: (v) => (this.flags = v) },
			{ key: "onlySelection", label: "Use only selection", type: "toggle", val: this.onlySelection, set: (v) => (this.onlySelection = v) },
			{ key: "replaceAll", label: "Replace all", type: "toggle", val: this.replaceAll, set: (v) => (this.replaceAll = v) },
			{ key: "preview", label: "Preview", type: "button", cta: false, handler: () => this.showPreview(previewEl) },
			{ key: "execute", label: "Execute", type: "button", cta: true, handler: () => {
				this.close();
				this.onSubmit(this.pattern, this.replacement, this.flags, this.onlySelection, this.replaceAll);
			}}
		];

		fields.forEach(f => {
			const s = new Setting(contentEl).setName(f.type !== "button" ? f.label : "");
			if (f.type === "text") {
				s.addText((t) => {
					t.setValue(f.val).onChange(f.set);
					this.controls[f.key] = t;
				});
			}
			if (f.type === "toggle") {
				s.addToggle((tg) => {
					tg.setValue(f.val).onChange(f.set);
					this.controls[f.key] = tg;
				});
			}
			if (f.type === "button") {
				s.addButton((b) => {
					b.setButtonText(f.label).onClick(f.handler);
					if (f.cta) b.setCta();
				});
			}
		});
	}

	onClose() {
		this.contentEl.empty();
	}
}


Author

Developed by Frank Wisniewski

2 Likes

I made an upgrade on this to have the modal become a sidebar element which you can open in Hover editor, and fixed the before and after states and now you can click on before matches so you are taken to the lines in the file.

const { Plugin, WorkspaceLeaf, ItemView, Setting, Notice, Modal, Menu } = require("obsidian");

const VIEW_TYPE_REGEX_REPLACE = "regex-replace-view";
const MAX_MATCHES_TO_DISPLAY = 100;
const MAX_TEXT_LENGTH_FOR_AUTO_PREVIEW = 50000; // 50k characters
const DEBOUNCE_DELAY = 500; // ms

module.exports = class RegexReplaceSidebarPlugin extends Plugin {
	async onload() {
		this.settings = Object.assign(
			{ 
				pattern: "", replacement: "", flags: "gm", 
				onlySelection: false, replaceAll: true,
				presets: [],
				autoPreview: true,
				currentPresetName: null
			}, 
			await this.loadData()
		);

		this.registerView(
			VIEW_TYPE_REGEX_REPLACE,
			(leaf) => new RegexReplaceView(leaf, this)
		);

		this.addRibbonIcon("replace", "Regex Replace", () => {
			this.activateView();
		});

		this.addCommand({
			id: "open-regex-replace-sidebar",
			name: "Open Regex Replace Sidebar",
			callback: () => this.activateView(),
		});

		this.addCommand({
			id: "regex-search-replace",
			name: "Regex Search & Replace (Quick)",
			editorCallback: (editor) => {
				const view = this.getRegexReplaceView();
				if (view) {
					view.executeReplace(editor);
				}
			},
		});

		this.registerEvent(
			this.app.workspace.on("editor-menu", (menu, editor) =>
				menu.addItem((item) =>
					item.setTitle("Regex Search & Replace").setIcon("search").onClick(() => {
						this.activateView();
						const view = this.getRegexReplaceView();
						if (view) {
							view.setCurrentEditor(editor);
						}
					})
				)
			)
		);
	}

	onunload() {
		this.app.workspace.detachLeavesOfType(VIEW_TYPE_REGEX_REPLACE);
	}

	async activateView() {
		const { workspace } = this.app;

		let leaf = null;
		const leaves = workspace.getLeavesOfType(VIEW_TYPE_REGEX_REPLACE);

		if (leaves.length > 0) {
			leaf = leaves[0];
		} else {
			leaf = workspace.getRightLeaf(false);
			await leaf.setViewState({ type: VIEW_TYPE_REGEX_REPLACE, active: true });
		}

		workspace.revealLeaf(leaf);
	}

	getRegexReplaceView() {
		const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_REGEX_REPLACE);
		return leaves.length > 0 ? leaves[0].view : null;
	}
};

class RegexReplaceView extends ItemView {
	constructor(leaf, plugin) {
		super(leaf);
		this.plugin = plugin;
		this.currentEditor = null;
		this.controls = {};
		this.previewTimeout = null;
		this.approvedLargeFiles = new Set();
		this.currentPresetName = plugin.settings.currentPresetName || null;
		
		this.pattern = plugin.settings.pattern;
		this.replacement = plugin.settings.replacement;
		this.flags = plugin.settings.flags;
		// Enforce mutual exclusivity: onlySelection takes priority
		this.onlySelection = !!plugin.settings.onlySelection;
		this.replaceAll = this.onlySelection ? false : !!plugin.settings.replaceAll;
		this.autoPreview = plugin.settings.autoPreview;
	}

	getViewType() {
		return VIEW_TYPE_REGEX_REPLACE;
	}

	getDisplayText() {
		return "Regex Replace";
	}

	getIcon() {
		return "arrow-left-right";
	}

	setCurrentEditor(editor) {
		this.currentEditor = editor;
		this.updatePreview();
	}

	getCurrentEditor() {
		const markdownLeaves = this.app.workspace.getLeavesOfType("markdown");
		
		let mostRecentEditor = null;
		let mostRecentTime = 0;
		
		for (const leaf of markdownLeaves) {
			if (leaf.view?.editor && leaf.view.file) {
				const leafTime = leaf.activeTime || leaf.view.file.stat?.mtime || 0;
				if (leafTime > mostRecentTime) {
					mostRecentTime = leafTime;
					mostRecentEditor = leaf.view.editor;
				}
			}
		}
		
		if (mostRecentEditor) {
			this.currentEditor = mostRecentEditor;
		}
		
		return this.currentEditor;
	}
	
	getCurrentFilePath() {
		const editor = this.getCurrentEditor();
		if (!editor) return null;
		
		// Find the leaf/view associated with this editor
		const leaves = this.app.workspace.getLeavesOfType("markdown");
		for (const leaf of leaves) {
			if (leaf.view?.editor === editor && leaf.view?.file) {
				return leaf.view.file.path;
			}
		}
		return null;
	}

	loadPreset(p) {
		this.pattern = p.pattern;
		this.replacement = p.replacement;
		this.flags = p.flags;
		// Enforce mutual exclusivity: onlySelection takes priority
		this.onlySelection = !!p.onlySelection;
		this.replaceAll = this.onlySelection ? false : !!p.replaceAll;

		if (this.controls.pattern) this.controls.pattern.setValue(this.pattern);
		if (this.controls.replacement) this.controls.replacement.setValue(this.replacement);
		if (this.controls.flags) this.controls.flags.setValue(this.flags);
		this._syncToggleUI(); // handles both toggle buttons in one place

		this.updatePreview();
	}

	_syncToggleUI() {
		if (this.controls.selToggle) this.controls.selToggle.toggleClass("active", this.onlySelection);
		if (this.controls.allToggle) this.controls.allToggle.toggleClass("active", this.replaceAll);
	}

	updatePreview() {
		// Clear any pending preview update
		if (this.previewTimeout) {
			clearTimeout(this.previewTimeout);
		}

		// Only auto-update if autoPreview is enabled
		if (!this.autoPreview) {
			return;
		}

		// Debounce the preview update
		this.previewTimeout = setTimeout(() => {
			const previewEl = this.containerEl.querySelector('.regex-preview-results');
			if (previewEl) {
				this.showPreview(previewEl);
			}
		}, DEBOUNCE_DELAY);
	}

	showPreview(previewEl) {
		previewEl.empty();
		
		const editor = this.getCurrentEditor();
		if (!editor) {
			previewEl.createEl("div", { 
				text: "No editor found",
				attr: { style: "color: var(--text-muted); font-style: italic; padding: 8px;" }
			});
			return;
		}

		let flags = this.flags;
		if (!flags.includes("g")) flags += "g";

		let regex;
		try {
			if (!this.pattern) {
				previewEl.createEl("div", { 
					text: "Enter pattern",
					attr: { style: "color: var(--text-muted); font-style: italic; padding: 8px;" }
				});
				return;
			}
			// Interpret escape sequences in pattern for preview
			const interpretedPattern = this.interpretEscapeSequences(this.pattern);
			regex = new RegExp(interpretedPattern, flags);
		} catch (e) {
			return previewEl.createEl("div", { 
				text: "Invalid regex: " + e.message,
				attr: { style: "color: var(--text-error); padding: 8px;" }
			});
		}

		const text = this.onlySelection ? editor.getSelection() : editor.getValue();
		
		if (!text) {
			previewEl.createEl("div", { 
				text: this.onlySelection ? "No selection" : "Empty document",
				attr: { style: "color: var(--text-muted); font-style: italic; padding: 8px;" }
			});
			return;
		}

		// Safety check: warn if text is very large
		const currentFilePath = this.getCurrentFilePath();
		const fileApproved = currentFilePath && this.approvedLargeFiles.has(currentFilePath);
		
		if (text.length > MAX_TEXT_LENGTH_FOR_AUTO_PREVIEW && !fileApproved) {
			const warning = previewEl.createEl("div", {
				attr: { style: "padding: 12px; background: var(--background-secondary); border: 2px solid var(--interactive-accent); border-radius: 4px; margin: 8px;" }
			});
			warning.createEl("div", {
				text: "⚠️ Large document detected",
				attr: { style: "font-weight: 600; margin-bottom: 4px; color: var(--text-normal);" }
			});
			warning.createEl("div", {
				text: `Document has ${text.length.toLocaleString()} characters. Preview may be slow.`,
				attr: { style: "font-size: 0.85em; margin-bottom: 8px; color: var(--text-muted);" }
			});
			const manualBtn = warning.createEl("button", {
				text: "Generate Preview Anyway",
				attr: { style: "padding: 4px 8px; font-size: 0.85em; cursor: pointer; background: var(--interactive-accent); color: var(--text-on-accent); border: none; border-radius: 3px;" }
			});
			manualBtn.addEventListener("click", () => {
				if (currentFilePath) {
					this.approvedLargeFiles.add(currentFilePath);
				}
				this.showPreviewUnsafe(previewEl, text, regex);
			});
			return;
		}

		this.showPreviewUnsafe(previewEl, text, regex);
	}

	showPreviewUnsafe(previewEl, text, regex) {
		previewEl.empty();

		let matches;
		try {
			matches = [...text.matchAll(regex)];
		} catch (e) {
			return previewEl.createEl("div", { 
				text: "Error matching regex: " + e.message,
				attr: { style: "color: var(--text-error); padding: 8px;" }
			});
		}

		if (matches.length === 0) {
			previewEl.createEl("div", { 
				text: "No matches",
				attr: { style: "color: var(--text-muted); font-style: italic; padding: 8px;" }
			});
			return;
		}

		const displayCount = Math.min(matches.length, MAX_MATCHES_TO_DISPLAY);
		const hasMore = matches.length > MAX_MATCHES_TO_DISPLAY;

		// Count display at top
		const countEl = previewEl.createEl("div", {
			text: `${matches.length} match${matches.length !== 1 ? 'es' : ''}${hasMore ? ` (showing first ${displayCount})` : ''}`,
			attr: { style: "padding: 6px 8px; font-weight: 600; background: var(--background-secondary); border-bottom: 1px solid var(--background-modifier-border); position: sticky; top: 0; z-index: 1;" }
		});

		for (let i = 0; i < displayCount; i++) {
			const matchResult = matches[i];
			const match = matchResult[0];
			const matchStart = matchResult.index;
			const matchEnd = matchStart + match.length;

			const textBeforeMatch = text.slice(0, matchStart);
			const lineNumber = textBeforeMatch.split('\n').length - 1;
			const lineStart = textBeforeMatch.lastIndexOf('\n') + 1;
			const characterPosition = matchStart - lineStart;

			const contextStart = Math.max(0, matchStart - 20);
			const contextEnd = Math.min(text.length, matchEnd + 20);
			
			const beforeContext = text.slice(contextStart, matchStart);
			const afterContext = text.slice(matchEnd, contextEnd);
			
			let replacement;
			try {
				// Interpret escape sequences in replacement for preview
				const interpretedReplacement = this.interpretEscapeSequences(this.replacement);
				replacement = match.replace(new RegExp(this.interpretEscapeSequences(this.pattern), this.flags.replace('g', '')), interpretedReplacement);
			} catch (e) {
				replacement = "[Error in replacement]";
			}

			const matchBox = previewEl.createEl("div", {
				attr: { 
					style: "margin: 4px; padding: 6px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; cursor: pointer; transition: all 0.2s;",
					title: `Click to jump to line ${lineNumber + 1}`
				}
			});

			matchBox.addEventListener('mouseenter', () => {
				matchBox.style.background = 'var(--background-modifier-hover)';
				matchBox.style.borderColor = 'var(--interactive-accent)';
			});
			matchBox.addEventListener('mouseleave', () => {
				matchBox.style.background = 'var(--background-secondary)';
				matchBox.style.borderColor = 'var(--background-modifier-border)';
			});

			matchBox.addEventListener('click', () => {
				const targetEditor = this.getCurrentEditor();
				if (targetEditor) {
					targetEditor.setCursor({ line: lineNumber, ch: characterPosition });
					targetEditor.setSelection(
						{ line: lineNumber, ch: characterPosition },
						{ line: lineNumber, ch: characterPosition + match.length }
					);
					targetEditor.focus();
					targetEditor.scrollIntoView({ line: lineNumber, ch: characterPosition });
					new Notice(`Match ${i + 1}/${matches.length}`);
				}
			});

			// Match number and location
			matchBox.createEl("div", {
				text: `#${i + 1} · Line ${lineNumber + 1}`,
				attr: { style: "font-size: 0.7em; color: var(--text-muted); margin-bottom: 4px;" }
			});

			// Before
			const beforeDiv = matchBox.createEl("div", { attr: { style: "font-family: var(--font-monospace); font-size: 0.75em; margin-bottom: 2px; word-break: break-all;" }});
			beforeDiv.innerHTML = '<span style="color: var(--text-faint);">' + this.escapeHtml(beforeContext) + '</span><span style="background: var(--text-selection); font-weight: bold; padding: 1px 2px;">' + this.escapeHtml(match) + '</span><span style="color: var(--text-faint);">' + this.escapeHtml(afterContext) + '</span>';

			// Arrow
			matchBox.createEl("div", {
				text: "↓",
				attr: { style: "text-align: center; color: var(--text-accent); font-size: 0.8em; margin: 2px 0;" }
			});

			// After
			const afterDiv = matchBox.createEl("div", { attr: { style: "font-family: var(--font-monospace); font-size: 0.75em; word-break: break-all;" }});
			afterDiv.innerHTML = '<span style="color: var(--text-faint);">' + this.escapeHtml(beforeContext) + '</span><span style="background: var(--text-accent); color: var(--text-on-accent); font-weight: bold; padding: 1px 2px;">' + this.escapeHtml(replacement) + '</span><span style="color: var(--text-faint);">' + this.escapeHtml(afterContext) + '</span>';
		}

		if (hasMore) {
			previewEl.createEl("div", {
				text: `... and ${matches.length - displayCount} more matches`,
				attr: { style: "padding: 12px; text-align: center; color: var(--text-muted); font-style: italic; background: var(--background-secondary); margin: 4px; border-radius: 4px;" }
			});
		}
	}

	escapeHtml(text) {
		const div = document.createElement('div');
		div.textContent = text;
		return div.innerHTML;
	}

	interpretEscapeSequences(str) {
		// Convert common escape sequences to their actual characters
		return str
			.replace(/\\n/g, '\n')
			.replace(/\\r/g, '\r')
			.replace(/\\t/g, '\t')
			.replace(/\\0/g, '\0')
			.replace(/\\\\/g, '\\'); // Must be last to handle escaped backslashes
	}

	executeReplace(editor = null) {
		// Silent no-op when neither Sel nor All is active (check-only mode)
		if (!this.onlySelection && !this.replaceAll) return;

		const targetEditor = editor || this.getCurrentEditor();
		if (!targetEditor) {
			new Notice("No active editor found.");
			return;
		}

		try {
			let regexFlags = (this.flags || "").toString();
			// In document mode, respect replaceAll (strip 'g' if false).
			// In selection mode, keep flags as-is — user manages 'g' themselves.
			if (!this.onlySelection && !this.replaceAll) regexFlags = regexFlags.replace("g", "");
			
			// Interpret escape sequences in both pattern and replacement
			const interpretedPattern = this.interpretEscapeSequences(this.pattern);
			const interpretedReplacement = this.interpretEscapeSequences(this.replacement);
			
			const regex = new RegExp(interpretedPattern, regexFlags);

			if (this.onlySelection) {
				const sel = targetEditor.getSelection();
				if (!sel) return new Notice("No selection found.");
				targetEditor.replaceSelection(sel.replace(regex, interpretedReplacement));
			} else {
				const cursorPos = targetEditor.getCursor();
				const scrollInfo = targetEditor.getScrollInfo();
				
				const originalContent = targetEditor.getValue();
				const newContent = originalContent.replace(regex, interpretedReplacement);
				
				if (newContent !== originalContent) {
					targetEditor.setValue(newContent);
					
					try {
						const lineCount = targetEditor.lineCount();
						const targetLine = Math.min(cursorPos.line, lineCount - 1);
						const lineLength = targetEditor.getLine(targetLine)?.length || 0;
						const targetCh = Math.min(cursorPos.ch, lineLength);
						
						targetEditor.setCursor({ line: targetLine, ch: targetCh });
						targetEditor.scrollTo(scrollInfo.left, scrollInfo.top);
					} catch (e) {
						targetEditor.setCursor(0, 0);
					}
				}
			}

			this.plugin.settings = { 
				...this.plugin.settings, 
				pattern: this.pattern, 
				replacement: this.replacement, 
				flags: this.flags, 
				onlySelection: this.onlySelection, 
				replaceAll: this.replaceAll,
				autoPreview: this.autoPreview
			};
			this.plugin.saveData(this.plugin.settings);
			
			new Notice("Replace successful!");
			
			// Manual preview update after replace
			const previewEl = this.containerEl.querySelector('.regex-preview-results');
			if (previewEl) {
				this.showPreview(previewEl);
			}
		} catch (err) {
			new Notice("Regex error: " + err);
		}
	}

	async onOpen() {
		const container = this.containerEl.children[1];
		container.empty();
		container.addClass("regex-replace-sidebar");
		
		// If a preset was previously loaded, restore its actual values
		if (this.currentPresetName) {
			const savedPreset = this.plugin.settings.presets.find(p => p.name === this.currentPresetName);
			if (savedPreset) {
				// Restore the preset's actual values
				this.pattern = savedPreset.pattern;
				this.replacement = savedPreset.replacement;
				this.flags = savedPreset.flags;
				// Enforce mutual exclusivity: onlySelection takes priority
				this.onlySelection = !!savedPreset.onlySelection;
				this.replaceAll = this.onlySelection ? false : !!savedPreset.replaceAll;
			} else {
				// Preset was deleted, clear the name
				this.currentPresetName = null;
			}
		}
		
		// Add compact CSS
		container.createEl("style", {
			text: `
				.regex-replace-sidebar {
					display: flex;
					flex-direction: column;
					height: 100%;
					padding: 8px;
				}
				.regex-compact-controls {
					flex-shrink: 0;
					margin-bottom: 8px;
				}
				.regex-preview-container {
					flex: 1;
					min-height: 0;
					overflow-y: auto;
					border: 1px solid var(--background-modifier-border);
					border-radius: 4px;
					background: var(--background-primary);
				}
				.regex-preview-results {
					height: 100%;
				}
				.regex-action-bar {
					flex-shrink: 0;
					margin-top: 8px;
					display: flex;
					gap: 4px;
				}
				.regex-action-bar button {
					flex: 1;
					padding: 6px;
					font-size: 0.85em;
				}
				.regex-input {
					width: 100%;
					padding: 4px 6px;
					font-size: 0.85em;
					font-family: var(--font-monospace);
					border: 1px solid var(--background-modifier-border);
					border-radius: 4px;
					background: var(--background-primary);
					color: var(--text-normal);
					margin-bottom: 4px;
				}
				.regex-input:focus {
					outline: none;
					border-color: var(--interactive-accent);
				}
				.regex-label {
					font-size: 0.75em;
					font-weight: 500;
					color: var(--text-muted);
					margin-bottom: 2px;
					display: block;
				}
				.regex-flags-row {
					display: flex;
					gap: 8px;
					align-items: center;
					margin-bottom: 4px;
				}
				.regex-flags-input {
					flex: 1;
					padding: 4px 6px;
					font-size: 0.85em;
					border: 1px solid var(--background-modifier-border);
					border-radius: 4px;
					background: var(--background-primary);
					color: var(--text-normal);
				}
				.regex-toggle-group {
					display: flex;
					gap: 4px;
				}
				.regex-toggle-btn {
					padding: 4px 8px;
					font-size: 0.7em;
					border: 1px solid var(--background-modifier-border);
					border-radius: 3px;
					background: var(--background-secondary);
					color: var(--text-muted);
					cursor: pointer;
					transition: all 0.2s;
				}
				.regex-toggle-btn.active {
					background: var(--interactive-accent);
					color: var(--text-on-accent);
					border-color: var(--interactive-accent);
				}
				.regex-preset-header {
					display: flex;
					gap: 4px;
					margin-bottom: 6px;
				}
				.regex-preset-dropdown {
					flex: 1;
					padding: 4px 6px;
					font-size: 0.8em;
					border: 1px solid var(--background-modifier-border);
					border-radius: 4px;
					background: var(--background-primary);
				}
				.regex-menu-btn {
					padding: 4px 8px;
					font-size: 0.8em;
					border: 1px solid var(--background-modifier-border);
					border-radius: 4px;
					background: var(--background-secondary);
					cursor: pointer;
				}
			`
		});

		const controlsDiv = container.createDiv("regex-compact-controls");

		// Preset management
		const presetHeader = controlsDiv.createDiv("regex-preset-header");
		// Add current preset display FIRST (before event listener that references it)
		const currentPresetDisplay = controlsDiv.createEl("div", {
			text: this.currentPresetName || "",
			attr: {
				style: `display: ${this.currentPresetName ? 'block' : 'none'}; padding: 6px 8px; margin-bottom: 6px; background: var(--background-secondary); border-left: 3px solid var(--interactive-accent); border-radius: 3px; font-size: 0.8em; color: var(--text-muted); word-wrap: break-word; overflow-wrap: break-word; max-width: 100%;`
			}
		});
		if (this.currentPresetName) {
			currentPresetDisplay.title = this.currentPresetName;
		}

		const presetDropdown = presetHeader.createEl("select", { cls: "regex-preset-dropdown" });
		presetDropdown.createEl("option", { text: "Load preset...", value: "" });
		this.plugin.settings.presets.forEach(p => {
			presetDropdown.createEl("option", { text: p.name, value: p.name });
		});

		presetDropdown.addEventListener("change", (e) => {
			const val = e.target.value;
			if (val) {
				const preset = this.plugin.settings.presets.find(p => p.name === val);
				if (preset) {
					this.loadPreset(preset);
					this.currentPresetName = val;
					this.plugin.settings.currentPresetName = val;
					this.plugin.saveData(this.plugin.settings);
					if (currentPresetDisplay) {
						currentPresetDisplay.textContent = val;
						currentPresetDisplay.style.display = "block";
						currentPresetDisplay.title = val;
					}
					new Notice("Loaded: " + val);
				}
				presetDropdown.value = "";
			}
		});

		const menuBtn = presetHeader.createEl("button", { text: "⋮", cls: "regex-menu-btn" });
		menuBtn.addEventListener("click", (e) => {
			const menu = new Menu();
			
			menu.addItem((item) =>
				item.setTitle("Save current as preset").setIcon("save").onClick(() => {
					new PresetNameModal(this.app, (name) => {
						this.plugin.settings.presets = this.plugin.settings.presets.filter(p => p.name !== name);
						this.plugin.settings.presets.push({
							name,
							pattern: this.pattern,
							replacement: this.replacement,
							flags: this.flags,
							onlySelection: this.onlySelection,
							replaceAll: this.replaceAll
						});
						this.plugin.saveData(this.plugin.settings);
						
						// Refresh dropdown
						presetDropdown.innerHTML = "";
						presetDropdown.createEl("option", { text: "Load preset...", value: "" });
						this.plugin.settings.presets.forEach(p => {
							presetDropdown.createEl("option", { text: p.name, value: p.name });
						});
						
						new Notice("Preset saved: " + name);
					}).open();
				})
			);

			if (this.plugin.settings.presets.length > 0) {
				menu.addSeparator();
				menu.addItem((item) =>
					item.setTitle("Delete preset...").setIcon("trash").onClick(() => {
						const deleteMenu = new Menu();
						this.plugin.settings.presets.forEach(p => {
							deleteMenu.addItem((delItem) =>
								delItem.setTitle(p.name).onClick(async () => {
									this.plugin.settings.presets = this.plugin.settings.presets.filter(pr => pr.name !== p.name);
									await this.plugin.saveData(this.plugin.settings);
									
									presetDropdown.innerHTML = "";
									presetDropdown.createEl("option", { text: "Load preset...", value: "" });
									this.plugin.settings.presets.forEach(pr => {
										presetDropdown.createEl("option", { text: pr.name, value: pr.name });
									});
									
									new Notice("Deleted: " + p.name);
								})
							);
						});
						deleteMenu.showAtMouseEvent(e);
					})
				);
			}

			menu.addSeparator();
			menu.addItem((item) =>
				item.setTitle(this.autoPreview ? "Disable auto-preview" : "Enable auto-preview")
					.setIcon(this.autoPreview ? "eye-off" : "eye")
					.onClick(() => {
						this.autoPreview = !this.autoPreview;
						this.plugin.settings.autoPreview = this.autoPreview;
						this.plugin.saveData(this.plugin.settings);
						new Notice(this.autoPreview ? "Auto-preview enabled" : "Auto-preview disabled - use Refresh button");
					})
			);

			menu.showAtMouseEvent(e);
		});

		// Pattern input
		controlsDiv.createEl("span", { text: "Pattern", cls: "regex-label" });
		const patternInput = controlsDiv.createEl("input", { cls: "regex-input", value: this.pattern });
		patternInput.addEventListener("input", (e) => {
			this.pattern = e.target.value;
			this.updatePreview();
		});
		this.controls.pattern = { setValue: (v) => patternInput.value = v };

		// Replacement input
		controlsDiv.createEl("span", { text: "Replacement", cls: "regex-label" });
		const replacementInput = controlsDiv.createEl("input", { cls: "regex-input", value: this.replacement });
		replacementInput.addEventListener("input", (e) => {
			this.replacement = e.target.value;
			this.updatePreview();
		});
		this.controls.replacement = { setValue: (v) => replacementInput.value = v };

		// Flags and toggles row
		const flagsRow = controlsDiv.createDiv("regex-flags-row");
		
		const flagsInput = flagsRow.createEl("input", { cls: "regex-flags-input", value: this.flags, attr: { placeholder: "gim" } });
		flagsInput.addEventListener("input", (e) => {
			this.flags = e.target.value;
			this.updatePreview();
		});
		this.controls.flags = { setValue: (v) => flagsInput.value = v };

		const toggleGroup = flagsRow.createDiv("regex-toggle-group");
		
		// Store toggle references for sync helper
		const selToggle = toggleGroup.createEl("button", { text: "Sel", cls: "regex-toggle-btn" });
		const allToggle = toggleGroup.createEl("button", { text: "All", cls: "regex-toggle-btn" });
		this.controls.selToggle = selToggle;
		this.controls.allToggle = allToggle;
		this._syncToggleUI();

		// Mutual exclusivity: clicking active = deactivate (neither state), clicking inactive = activate it, deactivate other
		selToggle.addEventListener("click", () => {
			if (this.onlySelection) {
				// Already active → click again = deactivate → "neither" (check-only) state
				this.onlySelection = false;
			} else {
				// Activate Sel; force All off
				this.onlySelection = true;
				this.replaceAll = false;
			}
			this._syncToggleUI();
			this.updatePreview();
		});

		allToggle.addEventListener("click", () => {
			if (this.replaceAll) {
				// Already active → click again = deactivate → "neither" (check-only) state
				this.replaceAll = false;
			} else {
				// Activate All; force Sel off
				this.replaceAll = true;
				this.onlySelection = false;
			}
			this._syncToggleUI();
			this.updatePreview();
		});

		this.controls.onlySelection = { setValue: (v) => { this.onlySelection = !!v; this._syncToggleUI(); } };
		this.controls.replaceAll = { setValue: (v) => { this.replaceAll = !!v; this._syncToggleUI(); } };

		// Preview container
		const previewContainer = container.createDiv("regex-preview-container");
		const previewEl = previewContainer.createDiv("regex-preview-results");

		// Action bar
		const actionBar = container.createDiv("regex-action-bar");
		const refreshBtn = actionBar.createEl("button", { text: "↻ Refresh" });
		refreshBtn.addEventListener("click", () => {
			// Force immediate preview update
			if (this.previewTimeout) {
				clearTimeout(this.previewTimeout);
			}
			this.showPreview(previewEl);
		});

		const executeBtn = actionBar.createEl("button", { text: "Execute", cls: "mod-cta" });
		executeBtn.addEventListener("click", () => this.executeReplace());

		// Initial preview
		this.updatePreview();

		this.registerEvent(
			this.app.workspace.on("active-leaf-change", () => {
				this.currentEditor = null;
				this.updatePreview();
			})
		);
	}

	async onClose() {
		if (this.previewTimeout) {
			clearTimeout(this.previewTimeout);
		}
		this.plugin.settings = { 
			...this.plugin.settings, 
			pattern: this.pattern, 
			replacement: this.replacement, 
			flags: this.flags, 
			onlySelection: this.onlySelection, 
			replaceAll: this.replaceAll,
			autoPreview: this.autoPreview,
			currentPresetName: this.currentPresetName
		};
		await this.plugin.saveData(this.plugin.settings);
	}
}

class PresetNameModal extends Modal {
	constructor(app, onSubmit) {
		super(app);
		this.onSubmit = onSubmit;
	}

	onOpen() {
		const { contentEl } = this;
		contentEl.createEl("h2", { text: "Save Preset" });

		let name = "";

		new Setting(contentEl)
			.setName("Preset name")
			.addText((t) => t.setPlaceholder("e.g. Swap Names").onChange((val) => (name = val)));

		new Setting(contentEl)
			.addButton((b) => b.setButtonText("Save").setCta().onClick(() => {
				if (name) {
					this.close();
					this.onSubmit(name);
				}
			}))
			.addButton((b) => b.setButtonText("Cancel").onClick(() => this.close()));
	}

	onClose() {
		this.contentEl.empty();
	}
}

(main.js)

Edited file to ensure better sidebar element space management and added safety debounce and ability to show preview for file per session if allowed to generate once already.
Edited again to allow either Selection or All File Content only to be active and when no button is selected, zero replacements are allowed. Useful to save favourite searches without wishing to change file content.

3 Likes

Thanks for sharing your plugin. Did you evaluate the differences compared to to the Regex Find/Replace plugin by Martin Eder ( GitHub - Gru80/obsidian-regex-replace: Plugin for Obsidian, providing search/replace functionality which supports regular expressions and selections. · GitHub )? I did not compare them but Eder’s Plugin is 4 years old so it could be that yours is different/better?

I am not familiar with the plugin (I may have tried it before but don’t remember it).
I had Claude make a comparison for you:


Both plugins run inside Obsidian’s desktop app (Electron), which means both use V8’s JavaScript regex engine. The engine is identical.

Feature comparison — yours vs Gru80’s

Gru80’s plugin (last updated January 2022, no commits since):

  • Modal/dialog based — opens a popup, you type pattern and replacement, hit replace, dialog closes
  • Plain text or regex toggle
  • Selection vs whole document toggle
  • Case-insensitive flag via settings panel
  • Pre-fills find field from current selection
  • \n interpretation in replacement field only
  • Remembers last used pattern/replacement between sessions
  • No preset system
  • No live preview — you execute blind and see the result in the document
  • Mobile supported

Yours:

  • Persistent sidebar — always visible alongside your note, non-modal
    • Can be elevated into a draggable Hover Editor instance for better visibility and pin it. Especially handy on mobile devices.
  • Live preview with match highlighting, context display, and click-to-jump navigation
  • Match count shown before you commit
  • Full escape sequence support in both pattern and replacement (\n, \t, \r, \\)
  • User-editable flags field (you control g, m, i, s etc. directly)
  • Preset system — save/load/delete named patterns
  • Debounced auto-preview with large-file protection
  • Three-state Sel/All/Neither toggle (the feature I’ve added in the last edit)
  • Cursor/scroll position restoration after replace

The one thing Gru80 does that yours doesn’t

His \n interpretation is opt-in via a checkbox toggle in the dialog. Yours always interprets escape sequences, which is almost certainly the better default for a regex tool — but worth noting it’s not configurable if someone genuinely wants a literal \n in their replacement string. That’s an edge case but a real one.

How about our regex handling?

  • You use new RegExp(pattern, flags) with the user-supplied flags string, which is correct — you’re not hardcoding behaviour
  • matchAll requires the g flag, and you correctly ensure it’s present for preview
  • Your interpretEscapeSequences runs before new RegExp(), which is the right order — the escape interpretation happens at the string level, then the resulting string is compiled into a regex
  • One genuine subtlety: \n in a regex pattern has a different meaning than \n in a replacement. In the pattern, \n is a regex newline character match (correct). In the replacement, \n is a literal newline to insert (also correct). You handle both through the same interpretEscapeSequences function, which works, but if someone wanted to match a literal backslash-n in their document text they’d need \\\\n in the pattern field — that’s standard and expected behaviour, just worth knowing.

Overall your plugin is substantially more capable than Gru80’s. His is a quick-execute dialog; yours is a working environment for developing, testing, and saving regex operations.