feat: find and replace in editor (#689)

* feat: page find and replace

* * Refactor search and replace directory

* bugfix scroll

* Fix search and replace functionality for macOS and improve UX

- Fixed cmd+f shortcut to work on macOS (using 'Mod' key instead of 'Control')
- Added search functionality to title editor
- Fixed "Not found" message showing when search term is empty
- Fixed tooltip error when clicking replace button
- Changed replace button from icon to text for consistency
- Reduced width of search input fields for better UI
- Fixed result index after replace operation to prevent out-of-bounds error
- Added missing translation strings for search and replace dialog
- Updated tooltip to show platform-specific shortcuts (⌘F on Mac, Ctrl-F on others)

* Hide replace functionality for users with view-only permissions

- Added editable prop to SearchAndReplaceDialog component
- Pass editable state from PageEditor to SearchAndReplaceDialog
- Conditionally render replace button based on edit permissions
- Hide replace input section for view-only users
- Disable Alt+R shortcut when user lacks edit permissions

* Fix search dialog not closing properly when navigating away

- Clear all state (search text, replace text) when closing dialog
- Reset replace button visibility state on close
- Clear editor search term to remove highlights
- Ensure dialog closes properly when route changes

* fix: preserve text marks (comments, etc.) when replacing text in search and replace

- Collect all marks that span the text being replaced using nodesBetween
- Apply collected marks to the replacement text to maintain formatting
- Fixes issue where comment marks were being removed during text replacement

* ignore type error

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
fuscodev
2025-07-10 05:40:07 +02:00
committed by GitHub
parent f80004817c
commit 29388636bf
14 changed files with 903 additions and 13 deletions

View File

@ -0,0 +1,9 @@
import { atom } from "jotai";
type SearchAndReplaceAtomType = {
isOpen: boolean;
};
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
isOpen: false,
});

View File

@ -0,0 +1,312 @@
import {
ActionIcon,
Button,
Dialog,
Flex,
Input,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconLetterCase,
IconReplace,
IconSearch,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
import { useLocation } from "react-router-dom";
import classes from "./search-replace.module.css";
interface PageFindDialogDialogProps {
editor: ReturnType<typeof useEditor>;
editable?: boolean;
}
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
const { t } = useTranslation();
const [searchText, setSearchText] = useState("");
const [replaceText, setReplaceText] = useState("");
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
const inputRef = useRef(null);
const [replaceButton, replaceButtonToggle] = useToggle([
{ isReplaceShow: false, color: "gray" },
{ isReplaceShow: true, color: "blue" },
]);
const [caseSensitive, caseSensitiveToggle] = useToggle([
{ isCaseSensitive: false, color: "gray" },
{ isCaseSensitive: true, color: "blue" },
]);
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
};
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
setReplaceText(event.target.value);
};
const closeDialog = () => {
setSearchText("");
setReplaceText("");
setPageFindState({ isOpen: false });
// Reset replace button state when closing
if (replaceButton.isReplaceShow) {
replaceButtonToggle();
}
// Clear search term in editor
if (editor) {
editor.commands.setSearchTerm("");
}
};
const goToSelection = () => {
if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position: Range = results[resultIndex];
if (!position) return;
// @ts-ignore
editor.commands.setTextSelection(position);
const element = document.querySelector(".search-result-current");
if (element)
element.scrollIntoView({ behavior: "smooth", block: "center" });
editor.commands.setTextSelection(0);
};
const next = () => {
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
editor.commands.previousSearchResult();
goToSelection();
};
const replace = () => {
editor.commands.setReplaceTerm(replaceText);
editor.commands.replace();
goToSelection();
};
const replaceAll = () => {
editor.commands.setReplaceTerm(replaceText);
editor.commands.replaceAll();
};
useEffect(() => {
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
const handleOpenEvent = (e) => {
setPageFindState({ isOpen: true });
const selectedText = editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
);
if (selectedText !== "") {
setSearchText(selectedText);
}
inputRef.current?.focus();
inputRef.current?.select();
};
const handleCloseEvent = (e) => {
closeDialog();
};
useEffect(() => {
!pageFindState.isOpen && closeDialog();
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
return () => {
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
document.removeEventListener(
"closeFindDialogFromEditor",
handleCloseEvent,
);
};
}, [pageFindState.isOpen]);
useEffect(() => {
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
editor.commands.resetIndex();
goToSelection();
}, [caseSensitive]);
const resultsCount = useMemo(
() =>
searchText.trim() === ""
? ""
: editor?.storage?.searchAndReplace?.results.length > 0
? editor?.storage?.searchAndReplace?.resultIndex +
1 +
"/" +
editor?.storage?.searchAndReplace?.results.length
: t("Not found"),
[
searchText,
editor?.storage?.searchAndReplace?.resultIndex,
editor?.storage?.searchAndReplace?.results.length,
],
);
const location = useLocation();
useEffect(() => {
closeDialog();
}, [location]);
return (
<Dialog
className={classes.findDialog}
opened={pageFindState.isOpen}
size="lg"
radius="md"
w={"auto"}
position={{ top: 90, right: 50 }}
withBorder
transitionProps={{ transition: "slide-down" }}
>
<Stack gap="xs">
<Flex align="center" gap="xs">
<Input
ref={inputRef}
placeholder={t("Find")}
leftSection={<IconSearch size={16} />}
rightSection={
<Text size="xs" ta="right">
{resultsCount}
</Text>
}
rightSectionWidth="70"
rightSectionPointerEvents="all"
size="xs"
w={220}
onChange={searchInputEvent}
value={searchText}
autoFocus
onKeyDown={getHotkeyHandler([
["Enter", next],
["shift+Enter", previous],
["alt+C", caseSensitiveToggle],
//@ts-ignore
...(editable ? [["alt+R", replaceButtonToggle]] : []),
])}
/>
<ActionIcon.Group>
<Tooltip label={t("Previous match (Shift+Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={previous}>
<IconArrowNarrowUp
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("Next match (Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={next}>
<IconArrowNarrowDown
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("Match case (Alt+C)")}>
<ActionIcon
variant="subtle"
color={caseSensitive.color}
onClick={() => caseSensitiveToggle()}
>
<IconLetterCase
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
{editable && (
<Tooltip label={t("Replace")}>
<ActionIcon
variant="subtle"
color={replaceButton.color}
onClick={() => replaceButtonToggle()}
>
<IconReplace
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Close (Escape)")}>
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</Flex>
{replaceButton.isReplaceShow && editable && (
<Flex align="center" gap="xs">
<Input
placeholder={t("Replace")}
leftSection={<IconReplace size={16} />}
rightSection={<div></div>}
rightSectionPointerEvents="all"
size="xs"
w={180}
autoFocus
onChange={replaceInputEvent}
value={replaceText}
onKeyDown={getHotkeyHandler([
["Enter", replace],
["ctrl+alt+Enter", replaceAll],
])}
/>
<ActionIcon.Group>
<Tooltip label={t("Replace (Enter)")}>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={replace}
>
{t("Replace")}
</Button>
</Tooltip>
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={replaceAll}
>
{t("Replace all")}
</Button>
</Tooltip>
</ActionIcon.Group>
</Flex>
)}
</Stack>
</Dialog>
);
}
export default SearchAndReplaceDialog;

View File

@ -0,0 +1,10 @@
.findDialog{
@media print {
display: none;
}
}
.findDialog div[data-position="right"].mantine-Input-section {
justify-content: right;
padding-right: 8px;
}