528 lines
14 KiB
TypeScript
528 lines
14 KiB
TypeScript
import { CaretDown as ChevronDown, CaretRight as ChevronRight, CaretUp as ChevronUp, X } from '@phosphor-icons/react'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { EditorView } from '@codemirror/view'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { cn } from '@/lib/utils'
|
|
import { translate, type AppLocale } from '../lib/i18n'
|
|
import {
|
|
buildEditorFindReplacementChange,
|
|
buildEditorFindReplacementChanges,
|
|
clampEditorFindIndex,
|
|
findEditorMatches,
|
|
nextEditorFindIndex,
|
|
type EditorFindMatch,
|
|
type EditorFindOptions,
|
|
} from '../utils/editorFind'
|
|
|
|
export interface RawEditorFindRequest {
|
|
id: number
|
|
path: string
|
|
replace: boolean
|
|
}
|
|
|
|
interface RawEditorFindBarProps {
|
|
doc: string
|
|
locale?: AppLocale
|
|
onClose: () => void
|
|
onReplaceOpenChange: (open: boolean) => void
|
|
open: boolean
|
|
path: string
|
|
replaceOpen: boolean
|
|
request?: RawEditorFindRequest | null
|
|
viewRef: React.MutableRefObject<EditorView | null>
|
|
}
|
|
|
|
function selectMatch(view: EditorView, match: EditorFindMatch, focusEditor: boolean): void {
|
|
view.dispatch({
|
|
selection: { anchor: match.from, head: match.to },
|
|
effects: EditorView.scrollIntoView(match.from, { y: 'center' }),
|
|
})
|
|
if (focusEditor) view.focus()
|
|
}
|
|
|
|
function matchStatusText(
|
|
locale: AppLocale,
|
|
error: string | null,
|
|
activeIndex: number,
|
|
matchCount: number,
|
|
): string {
|
|
if (error === 'Invalid regex') return translate(locale, 'editor.find.invalidRegex')
|
|
if (error) return translate(locale, 'editor.find.regexMustMatchText')
|
|
if (matchCount === 0) return translate(locale, 'editor.find.noMatches')
|
|
return translate(locale, 'editor.find.matchCount', {
|
|
current: clampEditorFindIndex(activeIndex, matchCount) + 1,
|
|
total: matchCount,
|
|
})
|
|
}
|
|
|
|
function useRequestFocus({
|
|
inputRef,
|
|
onReplaceOpenChange,
|
|
open,
|
|
path,
|
|
request,
|
|
}: {
|
|
inputRef: React.RefObject<HTMLInputElement | null>
|
|
onReplaceOpenChange: (open: boolean) => void
|
|
open: boolean
|
|
path: string
|
|
request?: RawEditorFindRequest | null
|
|
}) {
|
|
useEffect(() => {
|
|
if (!open || !request || request.path !== path) return
|
|
if (request.replace) onReplaceOpenChange(true)
|
|
|
|
const frameId = requestAnimationFrame(() => {
|
|
inputRef.current?.focus()
|
|
inputRef.current?.select()
|
|
})
|
|
return () => cancelAnimationFrame(frameId)
|
|
}, [inputRef, onReplaceOpenChange, open, path, request])
|
|
}
|
|
|
|
function focusEditorOnNextFrame(viewRef: React.MutableRefObject<EditorView | null>): void {
|
|
requestAnimationFrame(() => viewRef.current?.focus())
|
|
}
|
|
|
|
function closeRawEditorFind(
|
|
onClose: () => void,
|
|
viewRef: React.MutableRefObject<EditorView | null>,
|
|
): void {
|
|
onClose()
|
|
focusEditorOnNextFrame(viewRef)
|
|
}
|
|
|
|
function handleRawEditorFindKeyDown(
|
|
event: React.KeyboardEvent<HTMLInputElement>,
|
|
close: () => void,
|
|
moveMatch: (direction: 1 | -1) => void,
|
|
): void {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
close()
|
|
return
|
|
}
|
|
if (event.key !== 'Enter') return
|
|
|
|
event.preventDefault()
|
|
moveMatch(event.shiftKey ? -1 : 1)
|
|
}
|
|
|
|
function selectActiveEditorFindMatch(
|
|
viewRef: React.MutableRefObject<EditorView | null>,
|
|
open: boolean,
|
|
activeMatch?: EditorFindMatch,
|
|
): void {
|
|
const view = viewRef.current
|
|
if (!open || !view || !activeMatch) return
|
|
selectMatch(view, activeMatch, false)
|
|
}
|
|
|
|
function replaceCurrentEditorFindMatch({
|
|
activeMatch,
|
|
options,
|
|
query,
|
|
replacement,
|
|
viewRef,
|
|
}: {
|
|
activeMatch?: EditorFindMatch
|
|
options: EditorFindOptions
|
|
query: string
|
|
replacement: string
|
|
viewRef: React.MutableRefObject<EditorView | null>
|
|
}): void {
|
|
const view = viewRef.current
|
|
if (!view || !activeMatch) return
|
|
|
|
const change = buildEditorFindReplacementChange(activeMatch, query, replacement, options)
|
|
view.dispatch({
|
|
changes: change,
|
|
selection: { anchor: change.from, head: change.from + change.insert.length },
|
|
effects: EditorView.scrollIntoView(change.from, { y: 'center' }),
|
|
})
|
|
view.focus()
|
|
}
|
|
|
|
function replaceAllEditorFindMatches({
|
|
matches,
|
|
options,
|
|
query,
|
|
replacement,
|
|
viewRef,
|
|
}: {
|
|
matches: readonly EditorFindMatch[]
|
|
options: EditorFindOptions
|
|
query: string
|
|
replacement: string
|
|
viewRef: React.MutableRefObject<EditorView | null>
|
|
}): boolean {
|
|
const view = viewRef.current
|
|
if (!view || matches.length === 0) return false
|
|
|
|
const changes = buildEditorFindReplacementChanges(matches, query, replacement, options)
|
|
view.dispatch({ changes })
|
|
view.focus()
|
|
return true
|
|
}
|
|
|
|
interface RawEditorFindController {
|
|
caseSensitive: boolean
|
|
close: () => void
|
|
findInputRef: React.RefObject<HTMLInputElement | null>
|
|
handleFindChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
|
handleFindKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
|
hasMatches: boolean
|
|
moveNext: () => void
|
|
movePrevious: () => void
|
|
query: string
|
|
regex: boolean
|
|
replaceAll: () => void
|
|
replaceCurrent: () => void
|
|
replacement: string
|
|
setReplacement: (value: string) => void
|
|
status: string
|
|
toggleCaseSensitive: () => void
|
|
toggleRegex: () => void
|
|
}
|
|
|
|
function useRawEditorFindController({
|
|
doc,
|
|
locale = 'en',
|
|
onClose,
|
|
onReplaceOpenChange,
|
|
open,
|
|
path,
|
|
request,
|
|
viewRef,
|
|
}: Omit<RawEditorFindBarProps, 'replaceOpen'>): RawEditorFindController {
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const [query, setQuery] = useState('')
|
|
const [replacement, setReplacement] = useState('')
|
|
const [regex, setRegex] = useState(false)
|
|
const [caseSensitive, setCaseSensitive] = useState(false)
|
|
const [activeIndex, setActiveIndex] = useState(0)
|
|
const options = useMemo<EditorFindOptions>(() => ({ caseSensitive, regex }), [caseSensitive, regex])
|
|
const result = useMemo(() => findEditorMatches(doc, query, options), [doc, options, query])
|
|
const clampedActiveIndex = clampEditorFindIndex(activeIndex, result.matches.length)
|
|
const activeMatch = result.matches.at(clampedActiveIndex)
|
|
const status = matchStatusText(locale, result.error, clampedActiveIndex, result.matches.length)
|
|
const hasMatches = result.matches.length > 0 && !result.error
|
|
|
|
useRequestFocus({ inputRef, onReplaceOpenChange, open, path, request })
|
|
|
|
useEffect(() => {
|
|
selectActiveEditorFindMatch(viewRef, open, activeMatch)
|
|
}, [activeMatch, open, viewRef])
|
|
|
|
const moveMatch = useCallback((direction: 1 | -1) => {
|
|
setActiveIndex((current) => nextEditorFindIndex(current, result.matches.length, direction))
|
|
}, [result.matches.length])
|
|
const movePrevious = useCallback(() => moveMatch(-1), [moveMatch])
|
|
const moveNext = useCallback(() => moveMatch(1), [moveMatch])
|
|
const handleFindChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
setQuery(event.target.value)
|
|
setActiveIndex(0)
|
|
}, [])
|
|
|
|
const close = useCallback(() => closeRawEditorFind(onClose, viewRef), [onClose, viewRef])
|
|
|
|
const handleFindKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
handleRawEditorFindKeyDown(event, close, moveMatch)
|
|
}, [close, moveMatch])
|
|
|
|
const replaceCurrent = useCallback(() => {
|
|
replaceCurrentEditorFindMatch({ activeMatch, options, query, replacement, viewRef })
|
|
}, [activeMatch, options, query, replacement, viewRef])
|
|
|
|
const replaceAll = useCallback(() => {
|
|
if (replaceAllEditorFindMatches({
|
|
matches: result.matches,
|
|
options,
|
|
query,
|
|
replacement,
|
|
viewRef,
|
|
})) {
|
|
setActiveIndex(0)
|
|
}
|
|
}, [options, query, replacement, result.matches, viewRef])
|
|
|
|
return {
|
|
caseSensitive,
|
|
close,
|
|
findInputRef: inputRef,
|
|
handleFindChange,
|
|
handleFindKeyDown,
|
|
hasMatches,
|
|
moveNext,
|
|
movePrevious,
|
|
query,
|
|
regex,
|
|
replaceAll,
|
|
replaceCurrent,
|
|
replacement,
|
|
setReplacement,
|
|
status,
|
|
toggleCaseSensitive: () => setCaseSensitive((value) => !value),
|
|
toggleRegex: () => setRegex((value) => !value),
|
|
}
|
|
}
|
|
|
|
type FindControlsProps = Pick<
|
|
RawEditorFindController,
|
|
| 'caseSensitive'
|
|
| 'close'
|
|
| 'findInputRef'
|
|
| 'handleFindChange'
|
|
| 'handleFindKeyDown'
|
|
| 'hasMatches'
|
|
| 'moveNext'
|
|
| 'movePrevious'
|
|
| 'query'
|
|
| 'regex'
|
|
| 'status'
|
|
| 'toggleCaseSensitive'
|
|
| 'toggleRegex'
|
|
> & {
|
|
locale: AppLocale
|
|
onReplaceOpenChange: (open: boolean) => void
|
|
replaceOpen: boolean
|
|
}
|
|
|
|
function FindControls({
|
|
caseSensitive,
|
|
close,
|
|
findInputRef,
|
|
handleFindChange,
|
|
handleFindKeyDown,
|
|
hasMatches,
|
|
locale,
|
|
moveNext,
|
|
movePrevious,
|
|
onReplaceOpenChange,
|
|
query,
|
|
regex,
|
|
replaceOpen,
|
|
status,
|
|
toggleCaseSensitive,
|
|
toggleRegex,
|
|
}: FindControlsProps) {
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={replaceOpen ? translate(locale, 'editor.find.hideReplace') : translate(locale, 'editor.find.showReplace')}
|
|
title={replaceOpen ? translate(locale, 'editor.find.hideReplace') : translate(locale, 'editor.find.showReplace')}
|
|
onClick={() => onReplaceOpenChange(!replaceOpen)}
|
|
>
|
|
<ChevronRight className={cn('transition-transform', replaceOpen && 'rotate-90')} />
|
|
</Button>
|
|
<Input
|
|
ref={findInputRef}
|
|
type="search"
|
|
aria-label={translate(locale, 'editor.find.findLabel')}
|
|
placeholder={translate(locale, 'editor.find.findPlaceholder')}
|
|
value={query}
|
|
onChange={handleFindChange}
|
|
onKeyDown={handleFindKeyDown}
|
|
className="h-7 min-w-[12rem] flex-1 rounded px-2 text-xs"
|
|
data-testid="raw-editor-find-input"
|
|
/>
|
|
<span
|
|
className="min-w-[4.75rem] text-right text-xs text-muted-foreground"
|
|
aria-live="polite"
|
|
data-testid="raw-editor-find-count"
|
|
>
|
|
{status}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={translate(locale, 'editor.find.previousMatch')}
|
|
title={translate(locale, 'editor.find.previousMatch')}
|
|
disabled={!hasMatches}
|
|
onClick={movePrevious}
|
|
>
|
|
<ChevronUp />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={translate(locale, 'editor.find.nextMatch')}
|
|
title={translate(locale, 'editor.find.nextMatch')}
|
|
disabled={!hasMatches}
|
|
onClick={moveNext}
|
|
>
|
|
<ChevronDown />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={regex ? 'secondary' : 'ghost'}
|
|
size="xs"
|
|
aria-label={translate(locale, 'editor.find.regex')}
|
|
aria-pressed={regex}
|
|
title={translate(locale, 'editor.find.regex')}
|
|
onClick={toggleRegex}
|
|
>
|
|
.*
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={caseSensitive ? 'secondary' : 'ghost'}
|
|
size="xs"
|
|
aria-label={translate(locale, 'editor.find.matchCase')}
|
|
aria-pressed={caseSensitive}
|
|
title={translate(locale, 'editor.find.matchCase')}
|
|
onClick={toggleCaseSensitive}
|
|
>
|
|
Aa
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={translate(locale, 'editor.find.close')}
|
|
title={translate(locale, 'editor.find.close')}
|
|
onClick={close}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type ReplaceControlsProps = Pick<
|
|
RawEditorFindController,
|
|
'hasMatches' | 'replaceAll' | 'replaceCurrent' | 'replacement' | 'setReplacement'
|
|
> & {
|
|
locale: AppLocale
|
|
}
|
|
|
|
function ReplaceControls({
|
|
hasMatches,
|
|
locale,
|
|
replaceAll,
|
|
replaceCurrent,
|
|
replacement,
|
|
setReplacement,
|
|
}: ReplaceControlsProps) {
|
|
return (
|
|
<div className="ml-[1.875rem] flex min-w-0 items-center gap-1.5">
|
|
<Input
|
|
type="text"
|
|
aria-label={translate(locale, 'editor.find.replaceLabel')}
|
|
placeholder={translate(locale, 'editor.find.replacePlaceholder')}
|
|
value={replacement}
|
|
onChange={(event) => setReplacement(event.target.value)}
|
|
className="h-7 min-w-[12rem] flex-1 rounded px-2 text-xs"
|
|
data-testid="raw-editor-replace-input"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="xs"
|
|
disabled={!hasMatches}
|
|
onClick={replaceCurrent}
|
|
>
|
|
{translate(locale, 'editor.find.replace')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="xs"
|
|
disabled={!hasMatches}
|
|
onClick={replaceAll}
|
|
>
|
|
{translate(locale, 'editor.find.replaceAll')}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function RawEditorFindBar(props: RawEditorFindBarProps) {
|
|
const { locale = 'en', onReplaceOpenChange, open, replaceOpen } = props
|
|
const barRef = useRef<HTMLDivElement>(null)
|
|
const controller = useRawEditorFindController(props)
|
|
const {
|
|
caseSensitive,
|
|
close,
|
|
findInputRef,
|
|
handleFindChange,
|
|
handleFindKeyDown,
|
|
hasMatches,
|
|
moveNext,
|
|
movePrevious,
|
|
query,
|
|
regex,
|
|
replaceAll,
|
|
replaceCurrent,
|
|
replacement,
|
|
setReplacement,
|
|
status,
|
|
toggleCaseSensitive,
|
|
toggleRegex,
|
|
} = controller
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const bar = barRef.current
|
|
if (!bar) return
|
|
|
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
if (event.key !== 'Escape') return
|
|
event.preventDefault()
|
|
close()
|
|
}
|
|
|
|
bar.addEventListener('keydown', handleKeyDown)
|
|
return () => bar.removeEventListener('keydown', handleKeyDown)
|
|
}, [close, open])
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div
|
|
ref={barRef}
|
|
className="flex shrink-0 flex-col gap-1.5 border-b px-3 py-2"
|
|
data-testid="raw-editor-find-bar"
|
|
style={{
|
|
background: 'var(--surface-editor)',
|
|
borderColor: 'var(--border-subtle)',
|
|
}}
|
|
>
|
|
<FindControls
|
|
caseSensitive={caseSensitive}
|
|
close={close}
|
|
findInputRef={findInputRef}
|
|
handleFindChange={handleFindChange}
|
|
handleFindKeyDown={handleFindKeyDown}
|
|
hasMatches={hasMatches}
|
|
locale={locale}
|
|
moveNext={moveNext}
|
|
movePrevious={movePrevious}
|
|
onReplaceOpenChange={onReplaceOpenChange}
|
|
query={query}
|
|
regex={regex}
|
|
replaceOpen={replaceOpen}
|
|
status={status}
|
|
toggleCaseSensitive={toggleCaseSensitive}
|
|
toggleRegex={toggleRegex}
|
|
/>
|
|
{replaceOpen && (
|
|
<ReplaceControls
|
|
hasMatches={hasMatches}
|
|
locale={locale}
|
|
replaceAll={replaceAll}
|
|
replaceCurrent={replaceCurrent}
|
|
replacement={replacement}
|
|
setReplacement={setReplacement}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|