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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||||
import { GoToLineDialog } from './GoToLineDialog';
|
import { GoToLineDialog } from './GoToLineDialog';
|
||||||
|
import { MarkdownPreviewSearch } from './MarkdownPreviewSearch';
|
||||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
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 [copiedContent, setCopiedContent] = React.useState(false);
|
||||||
const [copiedPath, setCopiedPath] = React.useState(false);
|
const [copiedPath, setCopiedPath] = React.useState(false);
|
||||||
const [isGoToLineOpen, setIsGoToLineOpen] = 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 canCreateFile = Boolean(files.writeFile);
|
||||||
const canCreateFolder = Boolean(files.createDirectory);
|
const canCreateFolder = Boolean(files.createDirectory);
|
||||||
@@ -2915,6 +2921,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
setIsGoToLineOpen(true);
|
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 editorFontSize = useUIStore((state) => state.editorFontSize);
|
||||||
|
|
||||||
const editorExtensions = React.useMemo(() => {
|
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 && (
|
{isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -3842,34 +3893,50 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
|
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
|
||||||
<div className="oc-file-preview h-full overflow-auto p-3" ref={markdownPreviewRef}>
|
<div className="relative h-full min-h-0">
|
||||||
<FilePreviewCommentMenu
|
<div
|
||||||
containerRef={markdownPreviewRef}
|
className="oc-file-preview h-full overflow-auto p-3"
|
||||||
filePath={selectedFile.path}
|
ref={(node) => {
|
||||||
fileContent={fileContent}
|
markdownPreviewRef.current = node;
|
||||||
/>
|
mdPreviewContainerRef.current = node;
|
||||||
{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
|
<FilePreviewCommentMenu
|
||||||
content={fileContent}
|
containerRef={markdownPreviewRef}
|
||||||
className="typography-markdown-body"
|
filePath={selectedFile.path}
|
||||||
stripFrontmatter
|
fileContent={fileContent}
|
||||||
enableFileReferences={false}
|
|
||||||
/>
|
/>
|
||||||
</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>
|
</div>
|
||||||
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
|
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
|
||||||
isHtmlAssetAuthLoading ? (
|
isHtmlAssetAuthLoading ? (
|
||||||
@@ -4213,7 +4280,20 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
) : 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 ? (
|
{selectedFile ? (
|
||||||
<FilePreviewCommentMenu
|
<FilePreviewCommentMenu
|
||||||
containerRef={markdownPreviewRef}
|
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.disableLineWrap': 'Zeilenumbruch deaktivieren',
|
||||||
'filesView.editor.enableLineWrap': 'Zeilenumbruch aktivieren',
|
'filesView.editor.enableLineWrap': 'Zeilenumbruch aktivieren',
|
||||||
'filesView.editor.findInFile': 'In Datei suchen',
|
'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.goToLine': 'Gehe zu Zeile',
|
||||||
'filesView.editor.switchToEditMode': 'Zum Bearbeitungsmodus wechseln',
|
'filesView.editor.switchToEditMode': 'Zum Bearbeitungsmodus wechseln',
|
||||||
'filesView.editor.switchToPreviewMode': 'Zum Vorschau-Modus wechseln',
|
'filesView.editor.switchToPreviewMode': 'Zum Vorschau-Modus wechseln',
|
||||||
|
|||||||
@@ -1439,6 +1439,12 @@ export const dict = {
|
|||||||
'filesView.editor.disableLineWrap': 'Disable line wrap',
|
'filesView.editor.disableLineWrap': 'Disable line wrap',
|
||||||
'filesView.editor.enableLineWrap': 'Enable line wrap',
|
'filesView.editor.enableLineWrap': 'Enable line wrap',
|
||||||
'filesView.editor.findInFile': 'Find in file',
|
'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.goToLine': 'Go to line',
|
||||||
'filesView.editor.switchToEditMode': 'Switch to edit mode',
|
'filesView.editor.switchToEditMode': 'Switch to edit mode',
|
||||||
'filesView.editor.switchToPreviewMode': 'Switch to preview 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.disableLineWrap": "Desactivar ajuste de línea",
|
||||||
"filesView.editor.enableLineWrap": "Activar ajuste de línea",
|
"filesView.editor.enableLineWrap": "Activar ajuste de línea",
|
||||||
"filesView.editor.findInFile": "Buscar en el archivo",
|
"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.goToLine": "Ir a línea",
|
||||||
"filesView.editor.switchToEditMode": "Cambiar al modo de edición",
|
"filesView.editor.switchToEditMode": "Cambiar al modo de edición",
|
||||||
"filesView.editor.switchToPreviewMode": "Cambiar al modo de vista previa",
|
"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.disableLineWrap': 'Désactiver le retour à la ligne',
|
||||||
'filesView.editor.enableLineWrap': 'Activer le retour à la ligne',
|
'filesView.editor.enableLineWrap': 'Activer le retour à la ligne',
|
||||||
'filesView.editor.findInFile': 'Rechercher dans le fichier',
|
'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.goToLine': 'Aller à la ligne',
|
||||||
'filesView.editor.switchToEditMode': 'Passer en mode édition',
|
'filesView.editor.switchToEditMode': 'Passer en mode édition',
|
||||||
'filesView.editor.switchToPreviewMode': 'Passer en mode aperçu',
|
'filesView.editor.switchToPreviewMode': 'Passer en mode aperçu',
|
||||||
|
|||||||
@@ -1435,6 +1435,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.disableLineWrap': '行の折り返しを無効にする',
|
'filesView.editor.disableLineWrap': '行の折り返しを無効にする',
|
||||||
'filesView.editor.enableLineWrap': '行の折り返しを有効にする',
|
'filesView.editor.enableLineWrap': '行の折り返しを有効にする',
|
||||||
'filesView.editor.findInFile': 'ファイル内を検索',
|
'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.goToLine': '指定行に移動',
|
||||||
'filesView.editor.switchToEditMode': '編集モードに切り替え',
|
'filesView.editor.switchToEditMode': '編集モードに切り替え',
|
||||||
'filesView.editor.switchToPreviewMode': 'プレビューモードに切り替え',
|
'filesView.editor.switchToPreviewMode': 'プレビューモードに切り替え',
|
||||||
|
|||||||
@@ -1441,6 +1441,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.disableLineWrap': '줄 바꿈 끄기',
|
'filesView.editor.disableLineWrap': '줄 바꿈 끄기',
|
||||||
'filesView.editor.enableLineWrap': '줄 바꿈 켜기',
|
'filesView.editor.enableLineWrap': '줄 바꿈 켜기',
|
||||||
'filesView.editor.findInFile': '파일에서 찾기',
|
'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.goToLine': '줄로 이동',
|
||||||
'filesView.editor.switchToEditMode': '편집 모드로 전환',
|
'filesView.editor.switchToEditMode': '편집 모드로 전환',
|
||||||
'filesView.editor.switchToPreviewMode': '미리보기 모드로 전환',
|
'filesView.editor.switchToPreviewMode': '미리보기 모드로 전환',
|
||||||
|
|||||||
@@ -1953,6 +1953,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.enableLineWrap': 'Włącz zawijanie linii',
|
'filesView.editor.enableLineWrap': 'Włącz zawijanie linii',
|
||||||
'filesView.editor.exitFullscreen': 'Wyjdź z pełnego ekranu',
|
'filesView.editor.exitFullscreen': 'Wyjdź z pełnego ekranu',
|
||||||
'filesView.editor.findInFile': 'Znajdź w pliku',
|
'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.fullscreen': 'Pełny ekran',
|
||||||
'filesView.editor.goToLine': 'Przejdź do linii',
|
'filesView.editor.goToLine': 'Przejdź do linii',
|
||||||
'filesView.editor.htmlPreviewTitle': 'Podgląd HTML',
|
'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.disableLineWrap": "Desativar ajuste de linha",
|
||||||
"filesView.editor.enableLineWrap": "Ativar ajuste de linha",
|
"filesView.editor.enableLineWrap": "Ativar ajuste de linha",
|
||||||
"filesView.editor.findInFile": "Buscar no arquivo",
|
"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.goToLine": "Ir para linha",
|
||||||
"filesView.editor.switchToEditMode": "Alternar para o modo de edição",
|
"filesView.editor.switchToEditMode": "Alternar para o modo de edição",
|
||||||
"filesView.editor.switchToPreviewMode": "Alternar para o modo de visualizaçã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.disableLineWrap': 'Satır kaydırmayı devre dışı bırak',
|
||||||
'filesView.editor.enableLineWrap': 'Satır kaydırmayı etkinleştir',
|
'filesView.editor.enableLineWrap': 'Satır kaydırmayı etkinleştir',
|
||||||
'filesView.editor.findInFile': 'Dosyada bul',
|
'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.goToLine': 'Satıra git',
|
||||||
'filesView.editor.switchToEditMode': 'Düzenleme moduna geç',
|
'filesView.editor.switchToEditMode': 'Düzenleme moduna geç',
|
||||||
'filesView.editor.switchToPreviewMode': 'Önizleme moduna geç',
|
'filesView.editor.switchToPreviewMode': 'Önizleme moduna geç',
|
||||||
|
|||||||
@@ -1405,6 +1405,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"filesView.editor.disableLineWrap": "Вимкнути перенос рядків",
|
"filesView.editor.disableLineWrap": "Вимкнути перенос рядків",
|
||||||
"filesView.editor.enableLineWrap": "Увімкнути перенос рядків",
|
"filesView.editor.enableLineWrap": "Увімкнути перенос рядків",
|
||||||
"filesView.editor.findInFile": "Знайти у файлі",
|
"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.goToLine": "Перейти до рядка",
|
||||||
"filesView.editor.switchToEditMode": "Перемкнутися в режим редагування",
|
"filesView.editor.switchToEditMode": "Перемкнутися в режим редагування",
|
||||||
"filesView.editor.switchToPreviewMode": "Перемкнутися в режим попереднього перегляду",
|
"filesView.editor.switchToPreviewMode": "Перемкнутися в режим попереднього перегляду",
|
||||||
|
|||||||
@@ -1405,6 +1405,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.disableLineWrap': '关闭自动换行',
|
'filesView.editor.disableLineWrap': '关闭自动换行',
|
||||||
'filesView.editor.enableLineWrap': '开启自动换行',
|
'filesView.editor.enableLineWrap': '开启自动换行',
|
||||||
'filesView.editor.findInFile': '文件内查找',
|
'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.goToLine': '跳转到行',
|
||||||
'filesView.editor.switchToEditMode': '切换到编辑模式',
|
'filesView.editor.switchToEditMode': '切换到编辑模式',
|
||||||
'filesView.editor.switchToPreviewMode': '切换到预览模式',
|
'filesView.editor.switchToPreviewMode': '切换到预览模式',
|
||||||
|
|||||||
@@ -1416,6 +1416,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.disableLineWrap': '關閉自動換行',
|
'filesView.editor.disableLineWrap': '關閉自動換行',
|
||||||
'filesView.editor.enableLineWrap': '開啟自動換行',
|
'filesView.editor.enableLineWrap': '開啟自動換行',
|
||||||
'filesView.editor.findInFile': '檔案內尋找',
|
'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.goToLine': '跳轉到行',
|
||||||
'filesView.editor.switchToEditMode': '切換到編輯模式',
|
'filesView.editor.switchToEditMode': '切換到編輯模式',
|
||||||
'filesView.editor.switchToPreviewMode': '切換到預覽模式',
|
'filesView.editor.switchToPreviewMode': '切換到預覽模式',
|
||||||
|
|||||||
Reference in New Issue
Block a user