diff --git a/apps/x/apps/renderer/src/components/find-replace-bar.tsx b/apps/x/apps/renderer/src/components/find-replace-bar.tsx new file mode 100644 index 00000000..0a00a7cb --- /dev/null +++ b/apps/x/apps/renderer/src/components/find-replace-bar.tsx @@ -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(null) + const matchesRef = useRef([]) + 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 ( +
+
+ setQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Find..." + className="find-replace-input" + /> + + {matches.length > 0 ? `${currentIndex + 1} of ${matches.length}` : 'No results'} + + + + + +
+ {showReplace && ( +
+ setReplaceText(e.target.value)} + onKeyDown={handleReplaceKeyDown} + placeholder="Replace..." + className="find-replace-input" + /> + + +
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index f05fcdb5..90d0043d 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -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(null) const selectionHighlightRef = useRef(null) + const [showFindReplace, setShowFindReplace] = useState(false) const [wikiCommandValue, setWikiCommandValue] = useState('') 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 && ( + setShowFindReplace(false)} /> + )}
{wikiLinks ? ( diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 31ce2bf1..fcd61e18 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -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); +}