fix(ui): add in-document search to the Markdown file preview (#2697)
fix(ui): add in-document search to the Markdown file preview
This commit is contained in:
@@ -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';
|
||||
@@ -968,6 +969,11 @@ 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 mdFullscreenPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const canCreateFile = Boolean(files.writeFile);
|
||||
const canCreateFolder = Boolean(files.createDirectory);
|
||||
@@ -2915,6 +2921,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
setIsGoToLineOpen(true);
|
||||
});
|
||||
|
||||
// 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;
|
||||
if (target instanceof Element && 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(() => {
|
||||
@@ -3366,6 +3400,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>
|
||||
@@ -3842,34 +3893,50 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
|
||||
<div className="oc-file-preview h-full overflow-auto p-3" ref={markdownPreviewRef}>
|
||||
<FilePreviewCommentMenu
|
||||
containerRef={markdownPreviewRef}
|
||||
filePath={selectedFile.path}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
{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>
|
||||
}
|
||||
<div className="relative h-full min-h-0">
|
||||
<div
|
||||
className="oc-file-preview h-full overflow-auto p-3"
|
||||
ref={(node) => {
|
||||
markdownPreviewRef.current = node;
|
||||
mdPreviewContainerRef.current = node;
|
||||
}}
|
||||
>
|
||||
<SimpleMarkdownRenderer
|
||||
content={fileContent}
|
||||
className="typography-markdown-body"
|
||||
stripFrontmatter
|
||||
enableFileReferences={false}
|
||||
<FilePreviewCommentMenu
|
||||
containerRef={markdownPreviewRef}
|
||||
filePath={selectedFile.path}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{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>
|
||||
}
|
||||
>
|
||||
<SimpleMarkdownRenderer
|
||||
content={fileContent}
|
||||
className="typography-markdown-body"
|
||||
stripFrontmatter
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
{!isFullscreen && (
|
||||
<MarkdownPreviewSearch
|
||||
containerRef={mdPreviewContainerRef}
|
||||
open={mdPreviewFindOpen}
|
||||
onOpenChange={setMdPreviewFindOpen}
|
||||
focusNonce={mdPreviewFindFocusNonce}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
|
||||
isHtmlAssetAuthLoading ? (
|
||||
@@ -4213,7 +4280,20 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
) : null}
|
||||
</div>
|
||||
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
||||
<div className="oc-file-preview h-full overflow-auto p-4" ref={markdownPreviewRef}>
|
||||
<div
|
||||
className="oc-file-preview h-full overflow-auto p-4"
|
||||
ref={(node) => {
|
||||
markdownPreviewRef.current = node;
|
||||
mdFullscreenPreviewContainerRef.current = node;
|
||||
}}
|
||||
>
|
||||
<MarkdownPreviewSearch
|
||||
containerRef={mdFullscreenPreviewContainerRef}
|
||||
open={mdPreviewFindOpen}
|
||||
onOpenChange={setMdPreviewFindOpen}
|
||||
focusNonce={mdPreviewFindFocusNonce}
|
||||
className="right-4 top-16"
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<FilePreviewCommentMenu
|
||||
containerRef={markdownPreviewRef}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { findMatchRanges } from './markdownPreviewFind';
|
||||
|
||||
describe('findMatchRanges', () => {
|
||||
test('returns no ranges for an empty or whitespace-only query', () => {
|
||||
expect(findMatchRanges('hello world', '')).toEqual([]);
|
||||
expect(findMatchRanges('hello world', ' ')).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns no ranges when the query does not occur', () => {
|
||||
expect(findMatchRanges('hello world', 'nope')).toEqual([]);
|
||||
});
|
||||
|
||||
test('finds all non-overlapping occurrences', () => {
|
||||
expect(findMatchRanges('the quick brown fox jumps over the lazy dog', 'the')).toEqual([
|
||||
{ start: 0, end: 3 },
|
||||
{ start: 31, end: 34 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('matches case-insensitively', () => {
|
||||
expect(findMatchRanges('Hello HELLO hello', 'hello')).toEqual([
|
||||
{ start: 0, end: 5 },
|
||||
{ start: 6, end: 11 },
|
||||
{ start: 12, end: 17 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('scans non-overlapping matches like standard find-in-page', () => {
|
||||
expect(findMatchRanges('aaaa', 'aaa')).toEqual([{ start: 0, end: 3 }]);
|
||||
});
|
||||
|
||||
test('trims the query before matching', () => {
|
||||
expect(findMatchRanges('alpha beta', ' beta ')).toEqual([{ start: 6, end: 10 }]);
|
||||
});
|
||||
|
||||
test('handles a query longer than the text', () => {
|
||||
expect(findMatchRanges('abc', 'abcdef')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { findMatchRanges } from './markdownPreviewFind';
|
||||
|
||||
/**
|
||||
* In-preview text search for the rendered Markdown file preview.
|
||||
*
|
||||
* The preview renders as plain DOM (no iframe/shadow root), so browser-native
|
||||
* find works on web — but the Electron desktop shell has no find-in-page
|
||||
* implementation at all, and CodeMirror's search only exists in edit mode.
|
||||
* This widget provides the find shortcut behavior (Ctrl/Cmd+F) and a compact
|
||||
* search bar with match highlighting, navigation, and a live count, scoped to
|
||||
* the preview container.
|
||||
*
|
||||
* The rendered DOM is owned by the markdown renderer (block-level morphdom
|
||||
* reconciliation), so highlights are re-applied whenever the renderer mutates
|
||||
* the container (theme or content changes) via a MutationObserver; mutations
|
||||
* produced by this widget itself are ignored.
|
||||
*/
|
||||
const MARK_ATTR = 'data-md-find';
|
||||
const CURRENT_MARK_ATTR = 'data-md-find-current';
|
||||
const MARK_CLASS = 'rounded-[2px] bg-status-warning/30 text-foreground';
|
||||
const CURRENT_MARK_CLASS = 'rounded-[2px] bg-status-warning/60 text-foreground';
|
||||
/** Keystrokes re-walk the whole preview, so coalesce bursts of typing. */
|
||||
const SEARCH_DEBOUNCE_MS = 120;
|
||||
|
||||
const isMarkElement = (node: Node): boolean => {
|
||||
return node instanceof Element && node.hasAttribute(MARK_ATTR);
|
||||
};
|
||||
|
||||
const clearHighlights = (container: HTMLElement): void => {
|
||||
container.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
|
||||
const parent = mark.parentNode;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark);
|
||||
parent.normalize();
|
||||
});
|
||||
};
|
||||
|
||||
const applySearch = (container: HTMLElement, query: string): HTMLElement[] => {
|
||||
clearHighlights(container);
|
||||
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const marks: HTMLElement[] = [];
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
// Skipping svg (mermaid) keeps the highlight pass from corrupting
|
||||
// diagram rendering; script/style content is never visible anyway.
|
||||
if (parent.closest('svg, script, style')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (node instanceof Text) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of textNodes) {
|
||||
const text = node.nodeValue ?? '';
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const ranges = findMatchRanges(text, normalized);
|
||||
if (ranges.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = node.parentNode;
|
||||
if (!parent) {
|
||||
continue;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
for (const range of ranges) {
|
||||
if (range.start > cursor) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(cursor, range.start)));
|
||||
}
|
||||
const mark = document.createElement('mark');
|
||||
mark.setAttribute(MARK_ATTR, '');
|
||||
mark.className = MARK_CLASS;
|
||||
mark.textContent = text.slice(range.start, range.end);
|
||||
fragment.appendChild(mark);
|
||||
marks.push(mark);
|
||||
cursor = range.end;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(cursor)));
|
||||
}
|
||||
parent.replaceChild(fragment, node);
|
||||
}
|
||||
|
||||
return marks;
|
||||
};
|
||||
|
||||
type MarkdownPreviewSearchProps = {
|
||||
/** The scrollable preview container whose rendered text is searched. */
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Bumped every time the find shortcut is pressed to re-focus the input. */
|
||||
focusNonce: number;
|
||||
/** Layout overrides for the floating bar (position, offsets). */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
|
||||
containerRef,
|
||||
open,
|
||||
onOpenChange,
|
||||
focusNonce,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [index, setIndex] = React.useState(0);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const marksRef = React.useRef<HTMLElement[]>([]);
|
||||
const queryRef = React.useRef(query);
|
||||
queryRef.current = query;
|
||||
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Focus returns here when the bar closes, so Escape does not strand focus.
|
||||
const returnFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
const runSearch = React.useCallback((nextQuery: string) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
marksRef.current = [];
|
||||
setTotal(0);
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
marksRef.current = applySearch(container, nextQuery);
|
||||
setTotal(marksRef.current.length);
|
||||
setIndex(0);
|
||||
}, [containerRef]);
|
||||
|
||||
const scheduleSearch = React.useCallback((nextQuery: string) => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
runSearch(nextQuery);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}, [runSearch]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
const target = returnFocusRef.current;
|
||||
returnFocusRef.current = null;
|
||||
if (target?.isConnected) {
|
||||
target.focus();
|
||||
}
|
||||
}, [onOpenChange]);
|
||||
|
||||
// Re-apply highlights when the renderer re-morphs the container (theme or
|
||||
// content changes), ignoring mutations this widget produces itself. Only
|
||||
// active while the bar is open; closing clears the highlights.
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!open || !container) {
|
||||
return;
|
||||
}
|
||||
const observer = new MutationObserver((records) => {
|
||||
const fromUs = records.some((record) => {
|
||||
if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) {
|
||||
return true;
|
||||
}
|
||||
return [...record.addedNodes].some((node) => isMarkElement(node));
|
||||
});
|
||||
if (fromUs) {
|
||||
return;
|
||||
}
|
||||
runSearch(queryRef.current);
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearHighlights(container);
|
||||
};
|
||||
}, [containerRef, open, runSearch]);
|
||||
|
||||
// Focus the input when the bar opens, remembering what to restore on close.
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const previous = document.activeElement;
|
||||
if (previous instanceof HTMLElement && !returnFocusRef.current) {
|
||||
returnFocusRef.current = previous;
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Pressing the find shortcut again re-focuses and re-selects the query.
|
||||
React.useEffect(() => {
|
||||
if (open && focusNonce > 0) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [open, focusNonce]);
|
||||
|
||||
// Keep the current-match highlight and scroll it into view.
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.querySelectorAll(`mark[${CURRENT_MARK_ATTR}]`).forEach((mark) => {
|
||||
mark.removeAttribute(CURRENT_MARK_ATTR);
|
||||
mark.className = MARK_CLASS;
|
||||
});
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
const current = marksRef.current[Math.min(Math.max(index, 0), total - 1)];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.setAttribute(CURRENT_MARK_ATTR, '');
|
||||
current.className = CURRENT_MARK_CLASS;
|
||||
current.scrollIntoView({ block: 'nearest' });
|
||||
}, [containerRef, index, total]);
|
||||
|
||||
const goToNext = React.useCallback(() => {
|
||||
setIndex((current) => (total === 0 ? 0 : (current + 1) % total));
|
||||
}, [total]);
|
||||
|
||||
const goToPrevious = React.useCallback(() => {
|
||||
setIndex((current) => (total === 0 ? 0 : (current - 1 + total) % total));
|
||||
}, [total]);
|
||||
|
||||
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
goToPrevious();
|
||||
} else {
|
||||
goToNext();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}, [close, goToNext, goToPrevious]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('absolute right-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-border/60 bg-[var(--surface-elevated)] px-1.5 py-1 shadow-lg', className)}>
|
||||
<Icon name="search" className="ml-0.5 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
scheduleSearch(event.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('filesView.preview.find.placeholder')}
|
||||
aria-label={t('filesView.preview.find.placeholder')}
|
||||
className="h-7 w-40 rounded-md px-2 py-0 text-sm md:w-56"
|
||||
/>
|
||||
<span
|
||||
className="min-w-12 px-1 text-center typography-micro text-muted-foreground tabular-nums"
|
||||
aria-live="polite"
|
||||
aria-label={total > 0
|
||||
? t('filesView.preview.find.countAria', { current: index + 1, total })
|
||||
: undefined}
|
||||
>
|
||||
{query.trim() && total === 0
|
||||
? t('filesView.preview.find.noMatches')
|
||||
: total > 0
|
||||
? `${index + 1}/${total}`
|
||||
: ''}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-muted-foreground"
|
||||
onClick={goToPrevious}
|
||||
title={t('filesView.preview.find.previousAria')}
|
||||
aria-label={t('filesView.preview.find.previousAria')}
|
||||
disabled={total === 0}
|
||||
>
|
||||
<Icon name="arrow-up" className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-muted-foreground"
|
||||
onClick={goToNext}
|
||||
title={t('filesView.preview.find.nextAria')}
|
||||
aria-label={t('filesView.preview.find.nextAria')}
|
||||
disabled={total === 0}
|
||||
>
|
||||
<Icon name="arrow-down" className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-muted-foreground"
|
||||
onClick={close}
|
||||
title={t('filesView.preview.find.closeAria')}
|
||||
aria-label={t('filesView.preview.find.closeAria')}
|
||||
>
|
||||
<Icon name="close" className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Case-insensitive substring match ranges over a single text string, using
|
||||
* the same non-overlapping `String.prototype.indexOf` scan semantics as
|
||||
* standard find-in-page (e.g. "aaa" in "aaaa" yields a single [0,3]).
|
||||
*/
|
||||
export const findMatchRanges = (text: string, query: string): Array<{ start: number; end: number }> => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
if (!normalized) {
|
||||
return ranges;
|
||||
}
|
||||
const lower = text.toLowerCase();
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const index = lower.indexOf(normalized, cursor);
|
||||
if (index === -1) {
|
||||
break;
|
||||
}
|
||||
ranges.push({ start: index, end: index + normalized.length });
|
||||
cursor = index + normalized.length;
|
||||
}
|
||||
return ranges;
|
||||
};
|
||||
@@ -1244,6 +1244,12 @@ export const dict = {
|
||||
'filesView.editor.disableLineWrap': 'Zeilenumbruch deaktivieren',
|
||||
'filesView.editor.enableLineWrap': 'Zeilenumbruch aktivieren',
|
||||
'filesView.editor.findInFile': 'In Datei suchen',
|
||||
'filesView.preview.find.placeholder': 'In Vorschau suchen',
|
||||
'filesView.preview.find.nextAria': 'Nächster Treffer',
|
||||
'filesView.preview.find.previousAria': 'Vorheriger Treffer',
|
||||
'filesView.preview.find.closeAria': 'Suche schließen',
|
||||
'filesView.preview.find.noMatches': 'Keine Treffer',
|
||||
'filesView.preview.find.countAria': '{current} von {total}',
|
||||
'filesView.editor.goToLine': 'Gehe zu Zeile',
|
||||
'filesView.editor.switchToEditMode': 'Zum Bearbeitungsmodus wechseln',
|
||||
'filesView.editor.switchToPreviewMode': 'Zum Vorschau-Modus wechseln',
|
||||
|
||||
@@ -1439,6 +1439,12 @@ export const dict = {
|
||||
'filesView.editor.disableLineWrap': 'Disable line wrap',
|
||||
'filesView.editor.enableLineWrap': 'Enable line wrap',
|
||||
'filesView.editor.findInFile': 'Find in file',
|
||||
'filesView.preview.find.placeholder': 'Find in preview',
|
||||
'filesView.preview.find.nextAria': 'Next match',
|
||||
'filesView.preview.find.previousAria': 'Previous match',
|
||||
'filesView.preview.find.closeAria': 'Close search',
|
||||
'filesView.preview.find.noMatches': 'No matches',
|
||||
'filesView.preview.find.countAria': '{current} of {total}',
|
||||
'filesView.editor.goToLine': 'Go to line',
|
||||
'filesView.editor.switchToEditMode': 'Switch to edit mode',
|
||||
'filesView.editor.switchToPreviewMode': 'Switch to preview mode',
|
||||
|
||||
@@ -1405,6 +1405,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.disableLineWrap": "Desactivar ajuste de línea",
|
||||
"filesView.editor.enableLineWrap": "Activar ajuste de línea",
|
||||
"filesView.editor.findInFile": "Buscar en el archivo",
|
||||
"filesView.preview.find.placeholder": "Buscar en la vista previa",
|
||||
"filesView.preview.find.nextAria": "Siguiente coincidencia",
|
||||
"filesView.preview.find.previousAria": "Coincidencia anterior",
|
||||
"filesView.preview.find.closeAria": "Cerrar búsqueda",
|
||||
"filesView.preview.find.noMatches": "Sin coincidencias",
|
||||
"filesView.preview.find.countAria": "{current} de {total}",
|
||||
"filesView.editor.goToLine": "Ir a línea",
|
||||
"filesView.editor.switchToEditMode": "Cambiar al modo de edición",
|
||||
"filesView.editor.switchToPreviewMode": "Cambiar al modo de vista previa",
|
||||
|
||||
@@ -1205,6 +1205,12 @@ export const dict = {
|
||||
'filesView.editor.disableLineWrap': 'Désactiver le retour à la ligne',
|
||||
'filesView.editor.enableLineWrap': 'Activer le retour à la ligne',
|
||||
'filesView.editor.findInFile': 'Rechercher dans le fichier',
|
||||
'filesView.preview.find.placeholder': 'Rechercher dans l\'aperçu',
|
||||
'filesView.preview.find.nextAria': 'Correspondance suivante',
|
||||
'filesView.preview.find.previousAria': 'Correspondance précédente',
|
||||
'filesView.preview.find.closeAria': 'Fermer la recherche',
|
||||
'filesView.preview.find.noMatches': 'Aucune correspondance',
|
||||
'filesView.preview.find.countAria': '{current} sur {total}',
|
||||
'filesView.editor.goToLine': 'Aller à la ligne',
|
||||
'filesView.editor.switchToEditMode': 'Passer en mode édition',
|
||||
'filesView.editor.switchToPreviewMode': 'Passer en mode aperçu',
|
||||
|
||||
@@ -1435,6 +1435,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.disableLineWrap': '行の折り返しを無効にする',
|
||||
'filesView.editor.enableLineWrap': '行の折り返しを有効にする',
|
||||
'filesView.editor.findInFile': 'ファイル内を検索',
|
||||
'filesView.preview.find.placeholder': 'プレビュー内を検索',
|
||||
'filesView.preview.find.nextAria': '次の一致',
|
||||
'filesView.preview.find.previousAria': '前の一致',
|
||||
'filesView.preview.find.closeAria': '検索を閉じる',
|
||||
'filesView.preview.find.noMatches': '一致なし',
|
||||
'filesView.preview.find.countAria': '{total}件中{current}件目',
|
||||
'filesView.editor.goToLine': '指定行に移動',
|
||||
'filesView.editor.switchToEditMode': '編集モードに切り替え',
|
||||
'filesView.editor.switchToPreviewMode': 'プレビューモードに切り替え',
|
||||
|
||||
@@ -1441,6 +1441,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.disableLineWrap': '줄 바꿈 끄기',
|
||||
'filesView.editor.enableLineWrap': '줄 바꿈 켜기',
|
||||
'filesView.editor.findInFile': '파일에서 찾기',
|
||||
'filesView.preview.find.placeholder': '미리보기에서 찾기',
|
||||
'filesView.preview.find.nextAria': '다음 일치 항목',
|
||||
'filesView.preview.find.previousAria': '이전 일치 항목',
|
||||
'filesView.preview.find.closeAria': '검색 닫기',
|
||||
'filesView.preview.find.noMatches': '일치 항목 없음',
|
||||
'filesView.preview.find.countAria': '{total}개 중 {current}번째',
|
||||
'filesView.editor.goToLine': '줄로 이동',
|
||||
'filesView.editor.switchToEditMode': '편집 모드로 전환',
|
||||
'filesView.editor.switchToPreviewMode': '미리보기 모드로 전환',
|
||||
|
||||
@@ -1953,6 +1953,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.enableLineWrap': 'Włącz zawijanie linii',
|
||||
'filesView.editor.exitFullscreen': 'Wyjdź z pełnego ekranu',
|
||||
'filesView.editor.findInFile': 'Znajdź w pliku',
|
||||
'filesView.preview.find.placeholder': 'Szukaj w podglądzie',
|
||||
'filesView.preview.find.nextAria': 'Następne dopasowanie',
|
||||
'filesView.preview.find.previousAria': 'Poprzednie dopasowanie',
|
||||
'filesView.preview.find.closeAria': 'Zamknij wyszukiwanie',
|
||||
'filesView.preview.find.noMatches': 'Brak dopasowań',
|
||||
'filesView.preview.find.countAria': '{current} z {total}',
|
||||
'filesView.editor.fullscreen': 'Pełny ekran',
|
||||
'filesView.editor.goToLine': 'Przejdź do linii',
|
||||
'filesView.editor.htmlPreviewTitle': 'Podgląd HTML',
|
||||
|
||||
@@ -1405,6 +1405,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.disableLineWrap": "Desativar ajuste de linha",
|
||||
"filesView.editor.enableLineWrap": "Ativar ajuste de linha",
|
||||
"filesView.editor.findInFile": "Buscar no arquivo",
|
||||
"filesView.preview.find.placeholder": "Buscar na pré-visualização",
|
||||
"filesView.preview.find.nextAria": "Próxima correspondência",
|
||||
"filesView.preview.find.previousAria": "Correspondência anterior",
|
||||
"filesView.preview.find.closeAria": "Fechar busca",
|
||||
"filesView.preview.find.noMatches": "Sem correspondências",
|
||||
"filesView.preview.find.countAria": "{current} de {total}",
|
||||
"filesView.editor.goToLine": "Ir para linha",
|
||||
"filesView.editor.switchToEditMode": "Alternar para o modo de edição",
|
||||
"filesView.editor.switchToPreviewMode": "Alternar para o modo de visualização",
|
||||
|
||||
@@ -1414,6 +1414,12 @@ export const dict = {
|
||||
'filesView.editor.disableLineWrap': 'Satır kaydırmayı devre dışı bırak',
|
||||
'filesView.editor.enableLineWrap': 'Satır kaydırmayı etkinleştir',
|
||||
'filesView.editor.findInFile': 'Dosyada bul',
|
||||
'filesView.preview.find.placeholder': 'Önizlemede bul',
|
||||
'filesView.preview.find.nextAria': 'Sonraki eşleşme',
|
||||
'filesView.preview.find.previousAria': 'Önceki eşleşme',
|
||||
'filesView.preview.find.closeAria': 'Aramayı kapat',
|
||||
'filesView.preview.find.noMatches': 'Eşleşme yok',
|
||||
'filesView.preview.find.countAria': '{total} içinde {current}',
|
||||
'filesView.editor.goToLine': 'Satıra git',
|
||||
'filesView.editor.switchToEditMode': 'Düzenleme moduna geç',
|
||||
'filesView.editor.switchToPreviewMode': 'Önizleme moduna geç',
|
||||
|
||||
@@ -1405,6 +1405,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.disableLineWrap": "Вимкнути перенос рядків",
|
||||
"filesView.editor.enableLineWrap": "Увімкнути перенос рядків",
|
||||
"filesView.editor.findInFile": "Знайти у файлі",
|
||||
"filesView.preview.find.placeholder": "Пошук у попередньому перегляді",
|
||||
"filesView.preview.find.nextAria": "Наступний збіг",
|
||||
"filesView.preview.find.previousAria": "Попередній збіг",
|
||||
"filesView.preview.find.closeAria": "Закрити пошук",
|
||||
"filesView.preview.find.noMatches": "Збігів немає",
|
||||
"filesView.preview.find.countAria": "{current} із {total}",
|
||||
"filesView.editor.goToLine": "Перейти до рядка",
|
||||
"filesView.editor.switchToEditMode": "Перемкнутися в режим редагування",
|
||||
"filesView.editor.switchToPreviewMode": "Перемкнутися в режим попереднього перегляду",
|
||||
|
||||
@@ -1405,6 +1405,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.disableLineWrap': '关闭自动换行',
|
||||
'filesView.editor.enableLineWrap': '开启自动换行',
|
||||
'filesView.editor.findInFile': '文件内查找',
|
||||
'filesView.preview.find.placeholder': '在预览中查找',
|
||||
'filesView.preview.find.nextAria': '下一个匹配',
|
||||
'filesView.preview.find.previousAria': '上一个匹配',
|
||||
'filesView.preview.find.closeAria': '关闭搜索',
|
||||
'filesView.preview.find.noMatches': '无匹配项',
|
||||
'filesView.preview.find.countAria': '第 {current} 个,共 {total} 个',
|
||||
'filesView.editor.goToLine': '跳转到行',
|
||||
'filesView.editor.switchToEditMode': '切换到编辑模式',
|
||||
'filesView.editor.switchToPreviewMode': '切换到预览模式',
|
||||
|
||||
@@ -1416,6 +1416,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.disableLineWrap': '關閉自動換行',
|
||||
'filesView.editor.enableLineWrap': '開啟自動換行',
|
||||
'filesView.editor.findInFile': '檔案內尋找',
|
||||
'filesView.preview.find.placeholder': '在預覽中尋找',
|
||||
'filesView.preview.find.nextAria': '下一個相符項目',
|
||||
'filesView.preview.find.previousAria': '上一個相符項目',
|
||||
'filesView.preview.find.closeAria': '關閉搜尋',
|
||||
'filesView.preview.find.noMatches': '無相符項目',
|
||||
'filesView.preview.find.countAria': '第 {current} 個,共 {total} 個',
|
||||
'filesView.editor.goToLine': '跳轉到行',
|
||||
'filesView.editor.switchToEditMode': '切換到編輯模式',
|
||||
'filesView.editor.switchToPreviewMode': '切換到預覽模式',
|
||||
|
||||
Reference in New Issue
Block a user