diff --git a/CHANGELOG.md b/CHANGELOG.md
index 508f29c6a..afa089a1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,17 +11,18 @@ and this project adheres to
- ♿️(frontend) restore skip to content link after header redesign #2510
- 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486
- ✨(backend) conditional email notification in server to server api #2554
-
-### Fixed
-
-- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521
-- 🐛(backend) ignore CSPs for API docs in development
-- 🐛(frontend) export images embedded with a relative url #2573
+- ✨(frontend) add find and replace feature to the editor #2570
### Changed
- ♿️(frontend) use semantic `
` structure in document info card #2379
+### Fixed
+
+- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521
+- 🐛(backend) ignore CSPs for API docs in development #2538
+- 🐛(frontend) export images embedded with a relative url #2573
+
## [v5.4.1] - 2026-07-09
### Changed
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
index 052416722..a74ca9e00 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
@@ -602,13 +602,7 @@ test.describe('Doc Editor', () => {
page,
browserName,
}) => {
- const [docTitle] = await createDoc(
- page,
- 'doc-viewport-test',
- browserName,
- 1,
- );
- await verifyDocName(page, docTitle);
+ await createDoc(page, 'doc-viewport-test', browserName, 1);
const editor = await writeInEditor({
page,
@@ -637,4 +631,57 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Mobile Text')).toBeVisible();
});
+
+ test('it searches and replaces occurrences', async ({
+ page,
+ browserName,
+ }) => {
+ await createDoc(page, 'doc-search-replace', browserName);
+
+ const editor = await writeInEditor({
+ page,
+ text: 'World',
+ });
+
+ await writeInEditor({
+ page,
+ text: 'Hello World - Hello World',
+ });
+
+ // Open the find and replace panel
+ await page.keyboard.press('Control+f');
+
+ // Search for "Hello" and check that the occurrences are highlighted
+ await page.getByRole('textbox', { name: 'Find in document' }).fill('Hello');
+ await expect(page.getByText('1 / 2')).toBeVisible();
+ await expect(
+ editor
+ .locator('.find-and-replace-result-current')
+ .first()
+ .getByText('Hello'),
+ ).toBeVisible();
+ await expect(editor.locator('.find-and-replace-result')).toHaveCount(2);
+
+ await page.getByRole('button', { name: 'Next match' }).click();
+ await expect(page.getByText('2 / 2')).toBeVisible();
+
+ await page.keyboard.press('Escape');
+
+ // Select World then press Ctrl+f to check if the selected text is prefilled in the find input
+ await page.getByText('World').first().selectText();
+ await page.keyboard.press('Control+f');
+ await expect(
+ page.getByRole('textbox', { name: 'Find in document' }),
+ ).toHaveValue('World');
+
+ // Replace occurrences
+ await page.getByRole('button', { name: 'Next match' }).click();
+ await page.getByRole('button', { name: 'Toggle replace' }).click();
+ await page.getByRole('textbox', { name: 'Replace with' }).fill('Docs');
+ await page.getByRole('button', { name: 'Replace', exact: true }).click();
+ await expect(editor.getByText('Hello Docs - Hello World')).toBeVisible();
+ await page.getByRole('button', { name: 'Replace all' }).click();
+ await expect(editor.getByText('Docs', { exact: true })).toBeVisible();
+ await expect(editor.getByText('Hello Docs - Hello Docs')).toBeVisible();
+ });
});
diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json
index 030ee6eec..9fd99a1a0 100644
--- a/src/frontend/apps/impress/package.json
+++ b/src/frontend/apps/impress/package.json
@@ -51,6 +51,7 @@
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.69.0",
"@tanstack/react-query": "5.101.4",
+ "@tiptap/extension-find-and-replace": "3.29.2",
"@tiptap/extensions": "*",
"ai": "6.0.205",
"canvg": "4.0.3",
diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-down.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-down.svg
index 03a622704..bdbb762d9 100644
--- a/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-down.svg
+++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-down.svg
@@ -1,3 +1,6 @@
-
-
+
+
diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-squarepath.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-squarepath.svg
index e781d9bae..5e648542e 100644
--- a/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-squarepath.svg
+++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-squarepath.svg
@@ -1,4 +1,10 @@
-
-
-
+
+
+
diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-up.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-up.svg
index 33841f001..dec1f61e0 100644
--- a/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-up.svg
+++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-up.svg
@@ -1,3 +1,6 @@
-
-
+
+
diff --git a/src/frontend/apps/impress/src/components/modal/ButtonCloseModal.tsx b/src/frontend/apps/impress/src/components/modal/ButtonCloseModal.tsx
index 63cc8bc29..bd4924842 100644
--- a/src/frontend/apps/impress/src/components/modal/ButtonCloseModal.tsx
+++ b/src/frontend/apps/impress/src/components/modal/ButtonCloseModal.tsx
@@ -1,15 +1,25 @@
import { Button, type ButtonProps } from '@gouvfr-lasuite/cunningham-react';
+import type { ComponentProps } from 'react';
-import CloseIcon from '@/assets/icons/ui-kit/x-mark.svg';
+import CloseIcon from '@/icons/x-mark.svg';
-export const ButtonCloseModal = (props: ButtonProps) => {
+type ButtonCloseModalProps = ButtonProps & {
+ iconProps?: Omit, 'children'>;
+};
+
+export const ButtonCloseModal = ({
+ iconProps,
+ ...props
+}: ButtonCloseModalProps) => {
return (
}
+ icon={
+
+ }
{...props}
/>
);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
index e47ea82d4..d63a315e8 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
@@ -18,6 +18,7 @@ import {
useCreateBlockNote,
} from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
+import { FindAndReplace } from '@tiptap/extension-find-and-replace';
import { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
@@ -32,6 +33,7 @@ import {
useCommentSidebarStore,
useComments,
} from '@/docs/doc-comments';
+import { DocsFindReplaceStyle } from '@/docs/doc-find-replace/styles';
import { Doc } from '@/docs/doc-management';
import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
@@ -230,6 +232,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
CommentsExtension({ threadStore, resolveUsers }),
...(aiExtension ? [aiExtension] : []),
],
+ _tiptapOptions: {
+ extensions: [
+ FindAndReplace.configure({
+ injectCSS: false,
+ }),
+ ],
+ },
visualMedia: {
image: {
maxWidth: 760,
@@ -279,6 +288,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
canSeeComment={canSeeComment}
currentUserAvatarUrl={currentUserAvatarUrl}
/>
+
{errorAttachment && (
{
const { t } = useTranslation();
+ useFindReplaceShortcut(editor);
const handleFormattingShortcut = useCallback(
(event: KeyboardEvent) => {
diff --git a/src/frontend/apps/impress/src/features/docs/doc-find-replace/components/FindReplace.tsx b/src/frontend/apps/impress/src/features/docs/doc-find-replace/components/FindReplace.tsx
new file mode 100644
index 000000000..e8de4d515
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-find-replace/components/FindReplace.tsx
@@ -0,0 +1,240 @@
+import { Button } from '@gouvfr-lasuite/cunningham-react';
+import { KeyboardEvent, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import styled, { css } from 'styled-components';
+
+import { Box, ButtonCloseModal, Card, Text } from '@/components';
+import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
+import ArrowDownIcon from '@/icons/arrow-down.svg';
+import ArrowSquarepathIcon from '@/icons/arrow-squarepath.svg';
+import ArrowUpIcon from '@/icons/arrow-up.svg';
+import { useFocusStore } from '@/stores/useFocusStore';
+
+import { useFindReplace } from '../hooks/useFindReplace';
+import { useFindReplaceStore } from '../stores/useFindReplaceStore';
+
+const Input = styled.input`
+ flex: 1;
+ min-width: 0;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-size: 0.875rem;
+ color: inherit;
+ font-family: inherit;
+ padding: 0 var(--c--globals--spacings--2xs);
+`;
+
+export const FindReplace = () => {
+ const { t } = useTranslation();
+ const { editor } = useEditorStore();
+ const { close, openCount } = useFindReplaceStore();
+ const { restoreFocus } = useFocusStore();
+
+ const [isReplaceOpen, setIsReplaceOpen] = useState(false);
+ const findInputRef = useRef(null);
+
+ const {
+ query,
+ setQuery,
+ replacement,
+ setReplacement,
+ matchCount,
+ activeIndex,
+ goToNext,
+ goToPrevious,
+ replaceCurrent,
+ replaceAll,
+ } = useFindReplace(editor);
+
+ /**
+ * Pre-fill the find input with the currently selected
+ * text in the editor, if any.
+ * Gives the focus to the find input and selects its content so that
+ * the user can start typing a new query immediately.
+ */
+ useEffect(() => {
+ const tiptapEditor = editor?._tiptapEditor;
+ if (tiptapEditor) {
+ const { from, to } = tiptapEditor.state.selection;
+ const selectedText =
+ from !== to ? tiptapEditor.state.doc.textBetween(from, to, ' ') : '';
+
+ if (selectedText) {
+ setQuery(selectedText);
+ }
+ }
+
+ findInputRef.current?.focus();
+ findInputRef.current?.select();
+ }, [editor?._tiptapEditor, setQuery, openCount]);
+
+ const handleClose = () => {
+ close();
+ restoreFocus();
+ };
+
+ const handlePanelKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ handleClose();
+ }
+ };
+
+ const handleFindKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter') {
+ return;
+ }
+ event.preventDefault();
+ if (event.shiftKey) {
+ goToPrevious();
+ } else {
+ goToNext();
+ }
+ };
+
+ const handleReplaceKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter') {
+ return;
+ }
+ event.preventDefault();
+ replaceCurrent();
+ };
+
+ const hasMatches = matchCount > 0;
+ const counterLabel = hasMatches
+ ? `${activeIndex + 1} / ${matchCount}`
+ : '0 / 0';
+
+ return (
+
+
+ setQuery(event.target.value)}
+ onKeyDown={handleFindKeyDown}
+ placeholder={t('Find, replace...')}
+ aria-label={t('Find in document')}
+ />
+ {query && (
+ <>
+
+ {counterLabel}
+
+ }
+ color="neutral"
+ variant="tertiary"
+ size="nano"
+ />
+ }
+ color="neutral"
+ variant="tertiary"
+ size="nano"
+ />
+
+ >
+ )}
+ setIsReplaceOpen((prev) => !prev)}
+ icon={
+
+ }
+ />
+
+
+
+ {isReplaceOpen && (
+
+ setReplacement(event.target.value)}
+ onKeyDown={handleReplaceKeyDown}
+ placeholder={t('Replace by...')}
+ aria-label={t('Replace with')}
+ />
+
+ {t('Replace all')}
+
+
+ {t('Replace')}
+
+
+ )}
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-find-replace/hooks/useFindReplace.ts b/src/frontend/apps/impress/src/features/docs/doc-find-replace/hooks/useFindReplace.ts
new file mode 100644
index 000000000..2f5c3bf99
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-find-replace/hooks/useFindReplace.ts
@@ -0,0 +1,125 @@
+import { FormattingToolbarExtension } from '@blocknote/core/extensions';
+import { useCallback, useEffect, useState } from 'react';
+
+import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
+
+export const useFindReplace = (editor: DocsBlockNoteEditor | undefined) => {
+ const [query, setQueryState] = useState('');
+ const [replacement, setReplacementState] = useState('');
+ const [matchCount, setMatchCount] = useState(0);
+ const [activeIndex, setActiveIndex] = useState(-1);
+
+ const tiptapEditor = editor?._tiptapEditor;
+
+ const syncFromStorage = useCallback(() => {
+ if (!tiptapEditor) {
+ return;
+ }
+
+ const { results, currentIndex } = tiptapEditor.storage.findAndReplace;
+ setMatchCount(results.length);
+ setActiveIndex(currentIndex ?? -1);
+ }, [tiptapEditor]);
+
+ // The extension only calls ProseMirror's tr.scrollIntoView(), which doesn't
+ // reliably reach the active match through BlockNote's scroll container, so
+ // we scroll to it manually instead.
+ const scrollActiveIntoView = useCallback(() => {
+ if (!tiptapEditor) {
+ return;
+ }
+
+ const { results, currentIndex } = tiptapEditor.storage.findAndReplace;
+ const match = currentIndex !== null ? results[currentIndex] : undefined;
+ if (!match) {
+ return;
+ }
+
+ const { view } = tiptapEditor;
+ const domInfo = view.domAtPos(match.from);
+ const el =
+ domInfo.node.nodeType === 1
+ ? (domInfo.node as HTMLElement)
+ : domInfo.node.parentElement;
+
+ el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ }, [tiptapEditor]);
+
+ // Navigating/replacing matches moves the real editor selection, which
+ // triggers BlockNote's formatting toolbar. Force it closed since it isn't
+ // relevant while using find & replace.
+ const hideFormattingToolbar = useCallback(() => {
+ editor?.getExtension(FormattingToolbarExtension)?.store.setState(false);
+ }, [editor]);
+
+ // Reset the extension's state when the panel opens and subscribe to
+ // transactions to keep match count/active index in sync while it's open.
+ useEffect(() => {
+ if (!tiptapEditor) {
+ return;
+ }
+
+ tiptapEditor?.commands.clearSearch();
+ tiptapEditor?.commands.setReplaceTerm('');
+ syncFromStorage();
+
+ tiptapEditor.on('transaction', syncFromStorage);
+
+ return () => {
+ tiptapEditor.off('transaction', syncFromStorage);
+ tiptapEditor?.commands.clearSearch();
+ };
+ }, [tiptapEditor, syncFromStorage]);
+
+ const setQuery = useCallback(
+ (value: string) => {
+ setQueryState(value);
+ tiptapEditor?.commands.setSearchTerm(value);
+ requestAnimationFrame(scrollActiveIntoView);
+ },
+ [tiptapEditor, scrollActiveIntoView],
+ );
+
+ const setReplacement = useCallback(
+ (value: string) => {
+ setReplacementState(value);
+ tiptapEditor?.commands.setReplaceTerm(value);
+ },
+ [tiptapEditor],
+ );
+
+ const goToNext = useCallback(() => {
+ tiptapEditor?.commands.goToNextResult();
+ hideFormattingToolbar();
+ scrollActiveIntoView();
+ }, [tiptapEditor, scrollActiveIntoView, hideFormattingToolbar]);
+
+ const goToPrevious = useCallback(() => {
+ tiptapEditor?.commands.goToPreviousResult();
+ hideFormattingToolbar();
+ scrollActiveIntoView();
+ }, [tiptapEditor, scrollActiveIntoView, hideFormattingToolbar]);
+
+ const replaceCurrent = useCallback(() => {
+ tiptapEditor?.commands.replace();
+ hideFormattingToolbar();
+ requestAnimationFrame(scrollActiveIntoView);
+ }, [tiptapEditor, scrollActiveIntoView, hideFormattingToolbar]);
+
+ const replaceAll = useCallback(() => {
+ tiptapEditor?.commands.replaceAll();
+ }, [tiptapEditor]);
+
+ return {
+ query,
+ setQuery,
+ replacement,
+ setReplacement,
+ matchCount,
+ activeIndex,
+ goToNext,
+ goToPrevious,
+ replaceCurrent,
+ replaceAll,
+ };
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-find-replace/hooks/useFindReplaceShortcut.tsx b/src/frontend/apps/impress/src/features/docs/doc-find-replace/hooks/useFindReplaceShortcut.tsx
new file mode 100644
index 000000000..1a7c99e73
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-find-replace/hooks/useFindReplaceShortcut.tsx
@@ -0,0 +1,40 @@
+import { useEffect } from 'react';
+
+import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
+
+import { useFindReplaceStore } from '../stores/useFindReplaceStore';
+
+/**
+ * Binds Cmd/Ctrl+F to open the in-editor Find & Replace panel instead of the
+ * browser's native find bar.
+ */
+export const useFindReplaceShortcut = (
+ editor: DocsBlockNoteEditor | undefined,
+) => {
+ useEffect(() => {
+ if (!editor) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const isFindShortcut =
+ (event.metaKey || event.ctrlKey) &&
+ !event.shiftKey &&
+ !event.altKey &&
+ event.key.toLowerCase() === 'f';
+
+ if (!isFindShortcut) {
+ return;
+ }
+
+ event.preventDefault();
+ useFindReplaceStore.getState().open();
+ };
+
+ document.addEventListener('keydown', handleKeyDown, true);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown, true);
+ };
+ }, [editor]);
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-find-replace/stores/useFindReplaceStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-find-replace/stores/useFindReplaceStore.tsx
new file mode 100644
index 000000000..fb69dbc39
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-find-replace/stores/useFindReplaceStore.tsx
@@ -0,0 +1,22 @@
+import { create } from 'zustand';
+
+interface UseFindReplaceStore {
+ isOpen: boolean;
+ /**
+ * `openCount` gives us a way to rerender the FindReplace
+ * component when it is opened multiple times in a row.
+ * We use this to reset the input fields when the user
+ * opens the panel again.
+ */
+ openCount: number;
+ open: () => void;
+ close: () => void;
+}
+
+export const useFindReplaceStore = create((set) => ({
+ isOpen: false,
+ openCount: 0,
+ open: () =>
+ set((state) => ({ isOpen: true, openCount: state.openCount + 1 })),
+ close: () => set({ isOpen: false }),
+}));
diff --git a/src/frontend/apps/impress/src/features/docs/doc-find-replace/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-find-replace/styles.tsx
new file mode 100644
index 000000000..7b0ea8f9b
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-find-replace/styles.tsx
@@ -0,0 +1,18 @@
+import { createGlobalStyle } from 'styled-components';
+
+export const DocsFindReplaceStyle = createGlobalStyle`
+ .bn-root {
+ .find-and-replace-result {
+ border-radius: var(--c--globals--spacings--xxxs);
+ background: color-mix(
+ in srgb,
+ var(--c--contextuals--background--palette--yellow--tertiary) 35%,
+ transparent
+ );
+ mix-blend-mode: darken;
+ }
+ .find-and-replace-result-current {
+ background: var(--c--contextuals--background--palette--yellow--tertiary);
+ }
+ }
+`;
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocFloatingBar.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocFloatingBar.tsx
index 648caefed..b63ecd701 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocFloatingBar.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocFloatingBar.tsx
@@ -1,26 +1,33 @@
import { Box } from '@/components';
import { CardFloatingBar, FloatingBar } from '@/components/FloatingBar';
+import { FindReplace } from '@/docs/doc-find-replace/components/FindReplace';
+import { useFindReplaceStore } from '@/docs/doc-find-replace/stores/useFindReplaceStore';
import { useDocStore } from '@/docs/doc-management/stores/useDocStore';
-import { DocShareButton } from '@/features/docs/doc-share/components/DocShareButton';
+import { DocShareButton } from '@/docs/doc-share/components/DocShareButton';
import { RightPanelCollapseButton } from '@/features/right-panel/components/RightPanelCollapseButton';
import { DocLeftPanelCollapseButton } from './DocLeftPanelCollapseButton';
import { DocToolBox } from './DocToolBox';
export const DocFloatingBar = () => {
- const { currentDoc } = useDocStore();
+ const currentDoc = useDocStore((state) => state.currentDoc);
const isDeletedDoc = !!currentDoc?.deleted_at;
+ const isFindReplaceOpen = useFindReplaceStore((state) => state.isOpen);
return (
-
- {!isDeletedDoc && currentDoc && }
-
-
- {!isDeletedDoc && currentDoc && }
-
-
+ {isFindReplaceOpen ? (
+
+ ) : (
+
+ {!isDeletedDoc && currentDoc && }
+
+
+ {!isDeletedDoc && currentDoc && }
+
+
+ )}
);
};
diff --git a/src/frontend/apps/impress/src/features/skeletons/components/DocEditorSkeleton.tsx b/src/frontend/apps/impress/src/features/skeletons/components/DocEditorSkeleton.tsx
index 5399409d5..c1598aa78 100644
--- a/src/frontend/apps/impress/src/features/skeletons/components/DocEditorSkeleton.tsx
+++ b/src/frontend/apps/impress/src/features/skeletons/components/DocEditorSkeleton.tsx
@@ -46,7 +46,7 @@ const SkeletonEditorHeader = () => {
return (
{
- const { isDesktop } = useResponsiveStore();
-
return (