fix(ui): add in-document search to the Markdown file preview

The rendered Markdown preview had no way to search: the Electron desktop
shell implements no find-in-page at all, and CodeMirror's search panel only
exists in edit mode, so Ctrl/Cmd+F in the preview was a dead shortcut (web
browsers happen to find plain-DOM text natively, but desktop does not).

Adds a compact find bar for the rendered preview (Ctrl/Cmd+F or the search
button): case-insensitive match highlighting with a live count, Enter /
Shift+Enter and arrow buttons to navigate matches, Esc to close. Matches
are wrapped in <mark> elements and re-applied via MutationObserver when the
markdown renderer re-morphs the container (theme/content changes); svg
(mermaid) and script/style text is skipped. The pure match-range logic is
unit-tested.

Fixes #2401
This commit is contained in:
Serhii Dziupin
2026-08-05 14:05:12 +03:00
parent 34c221b07f
commit 17d5b90d83
15 changed files with 512 additions and 22 deletions
+80 -22
View File
@@ -24,6 +24,7 @@ import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { GoToLineDialog } from './GoToLineDialog';
import { MarkdownPreviewSearch } from './MarkdownPreviewSearch';
import { PreviewToggleButton } from './PreviewToggleButton';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
@@ -956,6 +957,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [copiedContent, setCopiedContent] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false);
// In-preview find for the rendered Markdown preview (Ctrl/Cmd+F).
const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false);
const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0);
const mdPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const canCreateFile = Boolean(files.writeFile);
const canCreateFolder = Boolean(files.createDirectory);
@@ -2945,6 +2950,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canEdit, isMobile, shortcutOverrides, textViewMode]);
// Ctrl/Cmd+F opens the in-preview find bar for the rendered Markdown
// preview. In edit mode CodeMirror owns the shortcut, so this handler is
// active only while the preview is shown.
React.useEffect(() => {
if (!isMarkdown || getMdViewMode() !== 'preview') {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey) {
return;
}
if (event.key.toLowerCase() !== 'f') {
return;
}
const target = event.target as Element | null;
if (target?.closest('[role="dialog"]')) {
return;
}
event.preventDefault();
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [getMdViewMode, isMarkdown]);
const editorFontSize = useUIStore((state) => state.editorFontSize);
const editorExtensions = React.useMemo(() => {
@@ -3392,6 +3425,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
)}
{isMarkdown && getMdViewMode() === 'preview' && (
withTooltip(t('filesView.editor.findInFile'),
<Button
variant="ghost"
size="sm"
onClick={() => {
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
}}
className="size-6 p-0 text-foreground opacity-100 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title={t('filesView.editor.findInFile')}
>
<Icon name="search" className="size-4" />
</Button>
)
)}
{isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && (
<Tooltip>
<TooltipTrigger asChild>
@@ -3932,29 +3982,37 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
</ErrorBoundary>
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
<div className="h-full overflow-auto p-3">
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('filesView.error.switchToEditMode')}
</div>
<div className="relative h-full min-h-0">
<div className="h-full overflow-auto p-3" ref={mdPreviewContainerRef}>
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
/>
</ErrorBoundary>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
/>
</ErrorBoundary>
</div>
<MarkdownPreviewSearch
containerRef={mdPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
/>
</div>
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
isHtmlAssetAuthLoading ? (