mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-03 12:22:38 +02:00
added find and replace
This commit is contained in:
parent
1f85bcf8ae
commit
52ac27ad2e
3 changed files with 339 additions and 0 deletions
261
apps/x/apps/renderer/src/components/find-replace-bar.tsx
Normal file
261
apps/x/apps/renderer/src/components/find-replace-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue