added find and replace

This commit is contained in:
Arjun 2026-02-23 21:51:51 +05:30
parent 1f85bcf8ae
commit 52ac27ad2e
3 changed files with 339 additions and 0 deletions

View file

@ -0,0 +1,261 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import type { Editor } from '@tiptap/react'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
ChevronUpIcon,
ChevronDownIcon,
XIcon,
ReplaceIcon,
} from 'lucide-react'
const findHighlightKey = new PluginKey('findHighlight')
type MatchRange = { from: number; to: number }
interface FindReplaceBarProps {
editor: Editor
onClose: () => void
}
export function FindReplaceBar({ editor, onClose }: FindReplaceBarProps) {
const [query, setQuery] = useState('')
const [replaceText, setReplaceText] = useState('')
const [showReplace, setShowReplace] = useState(false)
const [currentIndex, setCurrentIndex] = useState(-1)
// Bump this to force match recomputation after document changes (replace)
const [searchVersion, setSearchVersion] = useState(0)
const searchInputRef = useRef<HTMLInputElement>(null)
const matchesRef = useRef<MatchRange[]>([])
const currentIndexRef = useRef(-1)
const pluginRegistered = useRef(false)
// Compute matches from query + document (recomputed when searchVersion changes)
const matches = useMemo(() => {
if (!query) return []
const results: MatchRange[] = []
const lowerQuery = query.toLowerCase()
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text) {
const text = node.text.toLowerCase()
let idx = text.indexOf(lowerQuery)
while (idx !== -1) {
results.push({ from: pos + idx, to: pos + idx + query.length })
idx = text.indexOf(lowerQuery, idx + 1)
}
}
})
return results
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, editor, searchVersion])
// Reset currentIndex when matches change
const prevMatchCountRef = useRef(0)
useEffect(() => {
if (matches.length !== prevMatchCountRef.current) {
prevMatchCountRef.current = matches.length
setCurrentIndex(matches.length > 0 ? 0 : -1)
}
}, [matches])
// Keep refs in sync for the decoration plugin to read
useEffect(() => {
matchesRef.current = matches
currentIndexRef.current = currentIndex
// Force decoration re-render
editor.view.dispatch(editor.state.tr)
}, [matches, currentIndex, editor])
// Register/unregister the decoration plugin
useEffect(() => {
const plugin = new Plugin({
key: findHighlightKey,
props: {
decorations(state) {
const currentMatches = matchesRef.current
const idx = currentIndexRef.current
if (currentMatches.length === 0) return DecorationSet.empty
const decorations: Decoration[] = []
for (let i = 0; i < currentMatches.length; i++) {
const match = currentMatches[i]
if (match.from < 0 || match.to > state.doc.content.size) continue
const className = i === idx ? 'find-highlight find-highlight-current' : 'find-highlight'
decorations.push(Decoration.inline(match.from, match.to, { class: className }))
}
return DecorationSet.create(state.doc, decorations)
},
},
})
editor.registerPlugin(plugin)
pluginRegistered.current = true
return () => {
if (pluginRegistered.current) {
editor.unregisterPlugin(findHighlightKey)
pluginRegistered.current = false
editor.view.dispatch(editor.state.tr)
}
}
}, [editor])
// Scroll current match into view
useEffect(() => {
if (currentIndex >= 0 && currentIndex < matches.length) {
const match = matches[currentIndex]
editor.commands.setTextSelection(match)
editor.commands.scrollIntoView()
}
}, [currentIndex, matches, editor])
// Focus search input on mount
useEffect(() => {
searchInputRef.current?.focus()
searchInputRef.current?.select()
}, [])
const goToNext = useCallback(() => {
if (matches.length === 0) return
setCurrentIndex(prev => (prev + 1) % matches.length)
}, [matches.length])
const goToPrev = useCallback(() => {
if (matches.length === 0) return
setCurrentIndex(prev => (prev - 1 + matches.length) % matches.length)
}, [matches.length])
const handleReplace = useCallback(() => {
if (currentIndex < 0 || currentIndex >= matches.length) return
const match = matches[currentIndex]
editor.chain().focus().insertContentAt(
{ from: match.from, to: match.to },
replaceText
).run()
setSearchVersion(v => v + 1)
}, [currentIndex, matches, replaceText, editor])
const handleReplaceAll = useCallback(() => {
if (matches.length === 0) return
const chain = editor.chain().focus()
for (let i = matches.length - 1; i >= 0; i--) {
chain.insertContentAt({ from: matches[i].from, to: matches[i].to }, replaceText)
}
chain.run()
setSearchVersion(v => v + 1)
}, [matches, replaceText, editor])
const handleClose = useCallback(() => {
onClose()
editor.commands.focus()
}, [onClose, editor])
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
goToPrev()
} else if (e.key === 'Enter') {
e.preventDefault()
goToNext()
} else if (e.key === 'Escape') {
e.preventDefault()
handleClose()
}
}
const handleReplaceKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
handleClose()
}
}
return (
<div className="find-replace-bar">
<div className="find-replace-row">
<Input
ref={searchInputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Find..."
className="find-replace-input"
/>
<span className="find-replace-count">
{matches.length > 0 ? `${currentIndex + 1} of ${matches.length}` : 'No results'}
</span>
<Button
variant="ghost"
size="icon"
className="find-replace-btn"
onClick={goToPrev}
disabled={matches.length === 0}
title="Previous match (Shift+Enter)"
>
<ChevronUpIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="find-replace-btn"
onClick={goToNext}
disabled={matches.length === 0}
title="Next match (Enter)"
>
<ChevronDownIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="find-replace-btn"
onClick={() => setShowReplace(prev => !prev)}
title="Toggle replace"
data-active={showReplace || undefined}
>
<ReplaceIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="find-replace-btn"
onClick={handleClose}
title="Close (Escape)"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{showReplace && (
<div className="find-replace-row">
<Input
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
onKeyDown={handleReplaceKeyDown}
placeholder="Replace..."
className="find-replace-input"
/>
<Button
variant="ghost"
size="sm"
className="find-replace-action-btn"
onClick={handleReplace}
disabled={matches.length === 0}
>
Replace
</Button>
<Button
variant="ghost"
size="sm"
className="find-replace-action-btn"
onClick={handleReplaceAll}
disabled={matches.length === 0}
>
All
</Button>
</div>
)}
</div>
)
}

View file

@ -164,6 +164,7 @@ function getMarkdownWithBlankLines(editor: Editor): string {
return result
}
import { EditorToolbar } from './editor-toolbar'
import { FindReplaceBar } from './find-replace-bar'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
@ -273,6 +274,7 @@ export function MarkdownEditor({
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
const [showFindReplace, setShowFindReplace] = useState(false)
const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
@ -568,6 +570,10 @@ export function MarkdownEditor({
event.preventDefault()
// The parent component handles saving via onChange
}
if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setShowFindReplace(true)
}
}, [])
// Create image upload handler that shows placeholder
@ -583,6 +589,9 @@ export function MarkdownEditor({
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
/>
{showFindReplace && editor && (
<FindReplaceBar editor={editor} onClose={() => setShowFindReplace(false)} />
)}
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} />
{wikiLinks ? (

View file

@ -366,3 +366,72 @@
.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: rgba(255, 255, 255, 0.3);
}
/* Find & Replace Bar */
.find-replace-bar {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--border);
background-color: var(--background);
flex-shrink: 0;
}
.find-replace-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.find-replace-input {
flex: 1;
min-width: 0;
height: 1.75rem;
font-size: 0.8125rem;
}
.find-replace-count {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
padding: 0 0.25rem;
min-width: 4.5rem;
text-align: center;
}
.find-replace-btn {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
}
.find-replace-btn[data-active] {
background-color: var(--accent);
}
.find-replace-action-btn {
height: 1.75rem;
font-size: 0.75rem;
padding: 0 0.5rem;
flex-shrink: 0;
}
/* Find highlight decorations */
.tiptap-editor .ProseMirror .find-highlight {
background-color: rgba(255, 200, 0, 0.35);
border-radius: 2px;
}
.tiptap-editor .ProseMirror .find-highlight-current {
background-color: rgba(255, 140, 0, 0.6);
border-radius: 2px;
}
.dark .tiptap-editor .ProseMirror .find-highlight {
background-color: rgba(255, 200, 0, 0.25);
}
.dark .tiptap-editor .ProseMirror .find-highlight-current {
background-color: rgba(255, 140, 0, 0.5);
}