shadimage
Shadcn Tiptap
blocksearch-and-replacesource: ok · refreshed

Search And Replace

npx shadcn add "https://tiptap.niazmorshed.dev/r/search-and-replace.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files2

extensions/search-and-replace.tsx
import { type Editor as CoreEditor, Extension, type Range } from "@tiptap/core";
import type { Node as PMNode } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";

declare module "@tiptap/core" {
	interface Commands<ReturnType> {
		search: {
			/**
			 * @description Set search term in extension.
			 */
			setSearchTerm: (searchTerm: string) => ReturnType;
			/**
			 * @description Set replace term in extension.
			 */
			setReplaceTerm: (replaceTerm: string) => ReturnType;
			/**
			 * @description Replace first instance of search result with given replace term.
			 */
			replace: () => ReturnType;
			/**
			 * @description Replace all instances of search result with given replace term.
			 */
			replaceAll: () => ReturnType;
			/**
			 * @description Select the next search result.
			 */
			selectNextResult: () => ReturnType;
			/**
			 * @description Select the previous search result.
			 */
			selectPreviousResult: () => ReturnType;
			/**
			 * @description Set case sensitivity in extension.
			 */
			setCaseSensitive: (caseSensitive: boolean) => ReturnType;
		};
	}
}

interface TextNodeWithPosition {
	text: string;
	pos: number;
}

const getRegex = (
	searchString: string,
	disableRegex: boolean,
	caseSensitive: boolean,
): RegExp => {
	const escapedString = disableRegex
		? searchString.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
		: searchString;
	return new RegExp(escapedString, caseSensitive ? "gu" : "gui");
};

interface ProcessedSearches {
	decorationsToReturn: DecorationSet;
	results: Range[];
}

function processSearches(
	doc: PMNode,
	searchTerm: RegExp,
	selectedResultIndex: number,
	searchResultClass: string,
	selectedResultClass: string,
): ProcessedSearches {
	const decorations: Decoration[] = [];
	const results: Range[] = [];
	const textNodesWithPosition: TextNodeWithPosition[] = [];

	if (!searchTerm) {
		return { decorationsToReturn: DecorationSet.empty, results: [] };
	}

	doc.descendants((node, pos) => {
		if (node.isText) {
			textNodesWithPosition.push({ text: node.text || "", pos });
		}
	});

	for (const { text, pos } of textNodesWithPosition) {
		const matches = Array.from(text.matchAll(searchTerm)).filter(
			([matchText]) => matchText.trim(),
		);

		for (const match of matches) {
			if (match.index !== undefined) {
				results.push({
					from: pos + match.index,
					to: pos + match.index + match[0].length,
				});
			}
		}
	}

	for (let i = 0; i < results.length; i++) {
		const { from, to } = results[i];
		decorations.push(
			Decoration.inline(from, to, {
				class:
					selectedResultIndex === i ? selectedResultClass : searchResultClass,
			}),
		);
	}

	return {
		decorationsToReturn: DecorationSet.create(doc, decorations),
		results,
	};
}

const replace = (
	replaceTerm: string,
	results: Range[],
	{ state, dispatch }: any,
) => {
	const firstResult = results[0];

	if (!firstResult) {
		return;
	}

	const { from, to } = results[0];

	if (dispatch) {
		dispatch(state.tr.insertText(replaceTerm, from, to));
	}
};

const rebaseNextResult = (
	replaceTerm: string,
	index: number,
	lastOffset: number,
	results: Range[],
): [number, Range[]] | null => {
	const nextIndex = index + 1;

	if (!results[nextIndex]) {
		return null;
	}

	const { from: currentFrom, to: currentTo } = results[index];

	const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;

	const { from, to } = results[nextIndex];

	results[nextIndex] = {
		to: to - offset,
		from: from - offset,
	};

	return [offset, results];
};

const replaceAll = (
	replaceTerm: string,
	results: Range[],
	{ tr, dispatch }: { tr: any; dispatch: any },
) => {
	if (!results.length) {
		return;
	}

	let offset = 0;

	for (let i = 0; i < results.length; i++) {
		const { from, to } = results[i];
		tr.insertText(replaceTerm, from, to);
		const rebaseResponse = rebaseNextResult(replaceTerm, i, offset, results);

		if (rebaseResponse) {
			offset = rebaseResponse[0];
		}
	}

	dispatch(tr);
};

const selectNext = (editor: CoreEditor) => {
	const { results } = editor.storage
		.searchAndReplace as SearchAndReplaceStorage;

	if (!results.length) {
		return;
	}

	const { selectedResult } = editor.storage.searchAndReplace;

	if (selectedResult >= results.length - 1) {
		editor.storage.searchAndReplace.selectedResult = 0;
	} else {
		editor.storage.searchAndReplace.selectedResult += 1;
	}

	const { from } = results[editor.storage.searchAndReplace.selectedResult];

	const view: EditorView | undefined = editor.view;

	if (view) {
		view
			.domAtPos(from)
			// eslint-disable-next-line @typescript-eslint/ban-ts-comment
			// @ts-ignore
			.node.scrollIntoView({ behavior: "smooth", block: "center" });
	}
};

const selectPrevious = (editor: CoreEditor) => {
	const { results } = editor.storage.searchAndReplace;

	if (!results.length) {
		return;
	}

	const { selectedResult } = editor.storage.searchAndReplace;

	if (selectedResult <= 0) {
		editor.storage.searchAndReplace.selectedResult = results.length - 1;
	} else {
		editor.storage.searchAndReplace.selectedResult -= 1;
	}

	const { from } = results[editor.storage.searchAndReplace.selectedResult];

	const view: EditorView | undefined = editor.view;

	if (view) {
		view
			.domAtPos(from)
			// eslint-disable-next-line @typescript-eslint/ban-ts-comment
			// @ts-ignore
			.node.scrollIntoView({ behavior: "smooth", block: "center" });
	}
};

export const searchAndReplacePluginKey = new PluginKey(
	"searchAndReplacePlugin",
);

export interface SearchAndReplaceOptions {
	searchResultClass: string;
	selectedResultClass: string;
	disableRegex: boolean;
}

export interface SearchAndReplaceStorage {
	searchTerm: string;
	replaceTerm: string;
	results: Range[];
	lastSearchTerm: string;
	selectedResult: number;
	lastSelectedResult: number;
	caseSensitive: boolean;
	lastCaseSensitiveState: boolean;
}

export const SearchAndReplace = Extension.create<
	SearchAndReplaceOptions,
	SearchAndReplaceStorage
>({
	name: "searchAndReplace",

	addOptions() {
		return {
			searchResultClass: " bg-yellow-200",
			selectedResultClass: "bg-yellow-500",
			disableRegex: true,
		};
	},

	addStorage() {
		return {
			searchTerm: "",
			replaceTerm: "",
			results: [],
			lastSearchTerm: "",
			selectedResult: 0,
			lastSelectedResult: 0,
			caseSensitive: false,
			lastCaseSensitiveState: false,
		};
	},

	addCommands() {
		return {
			setSearchTerm:
				(searchTerm: string) =>
				({ editor }) => {
					editor.storage.searchAndReplace.searchTerm = searchTerm;

					return false;
				},
			setReplaceTerm:
				(replaceTerm: string) =>
				({ editor }) => {
					editor.storage.searchAndReplace.replaceTerm = replaceTerm;

					return false;
				},
			replace:
				() =>
				({ editor, state, dispatch }) => {
					const { replaceTerm, results } = editor.storage.searchAndReplace;

					replace(replaceTerm, results, { state, dispatch });

					return false;
				},
			replaceAll:
				() =>
				({ editor, tr, dispatch }) => {
					const { replaceTerm, results } = editor.storage.searchAndReplace;

					replaceAll(replaceTerm, results, { tr, dispatch });

					return false;
				},
			selectNextResult:
				() =>
				({ editor }) => {
					selectNext(editor);

					return false;
				},
			selectPreviousResult:
				() =>
				({ editor }) => {
					selectPrevious(editor);

					return false;
				},
			setCaseSensitive:
				(caseSensitive: boolean) =>
				({ editor }) => {
					editor.storage.searchAndReplace.caseSensitive = caseSensitive;

					return false;
				},
		};
	},

	addProseMirrorPlugins() {
		const editor = this.editor;
		const { searchResultClass, selectedResultClass, disableRegex } =
			this.options;

		const setLastSearchTerm = (t: string) => {
			editor.storage.searchAndReplace.lastSearchTerm = t;
		};

		const setLastSelectedResult = (r: number) => {
			editor.storage.searchAndReplace.lastSelectedResult = r;
		};

		const setLastCaseSensitiveState = (s: boolean) => {
			editor.storage.searchAndReplace.lastCaseSensitiveState = s;
		};

		return [
			new Plugin({
				key: searchAndReplacePluginKey,
				state: {
					init: () => DecorationSet.empty,
					apply({ doc, docChanged }, oldState) {
						const {
							searchTerm,
							selectedResult,
							lastSearchTerm,
							lastSelectedResult,
							caseSensitive,
							lastCaseSensitiveState,
						} = editor.storage.searchAndReplace as SearchAndReplaceStorage;

						if (
							!docChanged &&
							lastSearchTerm === searchTerm &&
							selectedResult === lastSelectedResult &&
							lastCaseSensitiveState === caseSensitive
						) {
							return oldState;
						}

						setLastSearchTerm(searchTerm);
						setLastSelectedResult(selectedResult);
						setLastCaseSensitiveState(caseSensitive);

						if (!searchTerm) {
							editor.storage.searchAndReplace.selectedResult = 0;
							editor.storage.searchAndReplace.results = [];
							return DecorationSet.empty;
						}

						const { decorationsToReturn, results } = processSearches(
							doc,
							getRegex(searchTerm, disableRegex, caseSensitive),
							selectedResult,
							searchResultClass,
							selectedResultClass,
						);

						editor.storage.searchAndReplace.results = results;

						if (selectedResult > results.length) {
							editor.storage.searchAndReplace.selectedResult = 1;
							editor.commands.selectPreviousResult();
						}

						return decorationsToReturn;
					},
				},
				props: {
					decorations(state) {
						return this.getState(state);
					},
				},
			}),
		];
	},
});

export default SearchAndReplace;
toolbars/search-and-replace-toolbar.tsx
"use client";

import { ArrowLeft, ArrowRight, Repeat, X } from "lucide-react";
import { useEffect, useState } from "react";

import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
	Popover,
	PopoverContent,
	PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import {
	Tooltip,
	TooltipContent,
	TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { SearchAndReplaceStorage } from "@/components/extensions/search-and-replace";
import { useToolbar } from "@/components/toolbars/toolbar-provider";

export function SearchAndReplaceToolbar() {
	const { editor } = useToolbar();

	const [open, setOpen] = useState(false);
	const [replacing, setReplacing] = useState(false);
	const [searchText, setSearchText] = useState("");
	const [replaceText, setReplaceText] = useState("");
	const [checked, setChecked] = useState(false);

	const results = editor?.storage?.searchAndReplace
		.results as SearchAndReplaceStorage["results"];
	const selectedResult = editor?.storage?.searchAndReplace
		.selectedResult as SearchAndReplaceStorage["selectedResult"];

	const replace = () => editor?.chain().replace().run();
	const replaceAll = () => editor?.chain().replaceAll().run();
	const selectNext = () => editor?.chain().selectNextResult().run();
	const selectPrevious = () => editor?.chain().selectPreviousResult().run();

	useEffect(() => {
		editor?.chain().setSearchTerm(searchText).run();
	}, [searchText, editor]);

	useEffect(() => {
		editor?.chain().setReplaceTerm(replaceText).run();
	}, [replaceText, editor]);

	useEffect(() => {
		editor?.chain().setCaseSensitive(checked).run();
	}, [checked, editor]);

	useEffect(() => {
		if (!open) {
			setReplaceText("");
			setSearchText("");
			setReplacing(false);
		}
	}, [open]);

	return (
		<Popover open={open}>
			<Tooltip>
				<TooltipTrigger asChild>
					<PopoverTrigger disabled={!editor} asChild>
						<Button
							variant="ghost"
							size="sm"
							onClick={() => {
								setOpen(!open);
							}}
							className={cn("h-8 w-max px-3 font-normal")}
						>
							<Repeat className="mr-2 h-4 w-4" />
							<p>Search & Replace</p>
						</Button>
					</PopoverTrigger>
				</TooltipTrigger>
				<TooltipContent>
					<span>Search & Replace</span>
				</TooltipContent>
			</Tooltip>

			<PopoverContent
				align="end"
				onCloseAutoFocus={(e) => {
					e.preventDefault();
				}}
				onEscapeKeyDown={() => {
					setOpen(false);
				}}
				className="relative flex w-[400px] px-3 py-2.5"
			>
				{!replacing ? (
					<div className={cn("relative flex gap-1.5 items-center")}>
						<Input
							value={searchText}
							className=" w-48"
							onChange={(e) => {
								setSearchText(e.target.value);
							}}
							placeholder="Search..."
						/>
						<span>
							{results?.length === 0 ? selectedResult : selectedResult + 1}/
							{results?.length}
						</span>
						<Button
							onClick={selectPrevious}
							size="icon"
							variant="ghost"
							className="size-7"
						>
							<ArrowLeft className="size-4" />
						</Button>
						<Button
							onClick={selectNext}
							size="icon"
							className="size-7"
							variant="ghost"
						>
							<ArrowRight className="h-4 w-4" />
						</Button>
						<Separator orientation="vertical" className="h-7 mx-0.5" />
						<Button
							onClick={() => {
								setReplacing(true);
							}}
							size="icon"
							className="size-7"
							variant="ghost"
						>
							<Repeat className="h-4 w-4" />
						</Button>
						<Button
							onClick={() => {
								setOpen(false);
							}}
							size="icon"
							className="size-7"
							variant="ghost"
						>
							<X className="h-4 w-4" />
						</Button>
					</div>
				) : (
					<div className={cn("relative w-full")}>
						<X
							onClick={() => {
								setOpen(false);
							}}
							className="absolute right-3 top-3 h-4 w-4 cursor-pointer"
						/>
						<div className="flex w-full items-center gap-3">
							<Button
								size="icon"
								className="size-7 rounded-full"
								variant="ghost"
								onClick={() => {
									setReplacing(false);
								}}
							>
								<ArrowLeft className="h-4 w-4" />
							</Button>
							<h2 className="text-sm font-medium">Search and replace</h2>
						</div>

						<div className="my-2 w-full">
							<div className="mb-3">
								<Label className="mb-1 text-xs text-gray-11">Search</Label>
								<Input
									value={searchText}
									onChange={(e) => {
										setSearchText(e.target.value);
									}}
									placeholder="Search..."
								/>
								{results?.length === 0 ? selectedResult : selectedResult + 1}/
								{results?.length}
							</div>
							<div className="mb-2">
								<Label className="mb-1 text-xs text-gray-11">
									Replace with
								</Label>
								<Input
									className="w-full"
									value={replaceText}
									onChange={(e) => {
										setReplaceText(e.target.value);
									}}
									placeholder="Replace..."
								/>
							</div>
							<div className="mt-3 flex items-center space-x-2">
								<Checkbox
									checked={checked}
									onCheckedChange={(checked: boolean) => {
										setChecked(checked);
									}}
									id="match_case"
								/>
								<Label
									htmlFor="match_case"
									className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
								>
									Match case
								</Label>
							</div>
						</div>

						<div className="actions mt-6 flex items-center justify-between">
							<div className="flex items-center gap-2">
								<Button
									onClick={selectPrevious}
									size="icon"
									className="h-7 w-7"
									variant="secondary"
								>
									<ArrowLeft className="h-4 w-4" />
								</Button>
								<Button
									onClick={selectNext}
									size="icon"
									className="h-7 w-7"
									variant="secondary"
								>
									<ArrowRight className="h-4 w-4" />
								</Button>
							</div>

							<div className="main-actions flex items-center gap-2">
								<Button
									size="sm"
									className="h-7 px-3 text-xs"
									variant="secondary"
									onClick={replaceAll}
								>
									Replace All
								</Button>
								<Button
									onClick={replace}
									size="sm"
									className="h-7 px-3 text-xs"
								>
									Replace
								</Button>
							</div>
						</div>
					</div>
				)}
			</PopoverContent>
		</Popover>
	);
}

More from Shadcn Tiptap

Browse all components in this library →