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>
);
}