From f42f8b5fd875cc2ae215f4dac9e75234a1c8784e Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 11 Aug 2026 10:56:37 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20find=20and=20replac?= =?UTF-8?q?e=20feature=20to=20the=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have added a new feature to the editor that allows users to find and replace text within their documents. This feature enhances the editing experience by providing a convenient way to search for specific words or phrases and replace them with new content. --- CHANGELOG.md | 13 +- .../__tests__/app-impress/doc-editor.spec.ts | 61 ++++- src/frontend/apps/impress/package.json | 1 + .../src/assets/icons/ui-kit/arrow-down.svg | 7 +- .../assets/icons/ui-kit/arrow-squarepath.svg | 12 +- .../src/assets/icons/ui-kit/arrow-up.svg | 7 +- .../src/components/modal/ButtonCloseModal.tsx | 16 +- .../doc-editor/components/BlockNoteEditor.tsx | 10 + .../docs/doc-editor/hook/useShortcuts.tsx | 3 + .../components/FindReplace.tsx | 240 +++++++++++++++++ .../doc-find-replace/hooks/useFindReplace.ts | 125 +++++++++ .../hooks/useFindReplaceShortcut.tsx | 40 +++ .../stores/useFindReplaceStore.tsx | 22 ++ .../features/docs/doc-find-replace/styles.tsx | 18 ++ .../doc-header/components/DocFloatingBar.tsx | 25 +- .../components/DocEditorSkeleton.tsx | 6 +- src/frontend/package.json | 4 + src/frontend/yarn.lock | 252 ++++++------------ 18 files changed, 657 insertions(+), 205 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-find-replace/components/FindReplace.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-find-replace/hooks/useFindReplace.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-find-replace/hooks/useFindReplaceShortcut.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-find-replace/stores/useFindReplaceStore.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-find-replace/styles.tsx 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 ( + + + + + {isReplaceOpen && ( + + + )} + + ); +}; 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 (