'use client' import { useState, useCallback, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { X, ChevronDown, ChevronUp, Replace, CaseSensitive, Regex } from 'lucide-react' import { useLanguage } from '@/lib/i18n' import { cn } from '@/lib/utils' import { Extension } from '@tiptap/core' import { Editor } from '@tiptap/core' import { Decoration, DecorationSet } from '@tiptap/pm/view' import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' interface Match { from: number to: number } const pluginKey = new PluginKey('findReplaceDecorations') interface DecorationState { decorations: DecorationSet currentIndex: number } function findMatches(doc: any, query: string, caseSensitive: boolean, useRegex: boolean): Match[] { if (!query) return [] let pattern: RegExp try { if (useRegex) { pattern = new RegExp(query, caseSensitive ? 'g' : 'gi') } else { pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSensitive ? 'g' : 'gi') } } catch { return [] } const results: Match[] = [] doc.descendants((node: any, pos: number) => { if (!node.isText || !node.text) return pattern.lastIndex = 0 let m: RegExpExecArray | null while ((m = pattern.exec(node.text)) !== null) { if (m[0].length === 0) { pattern.lastIndex++; continue } results.push({ from: pos + m.index, to: pos + m.index + m[0].length }) } }) return results } export const FindReplaceExtension = Extension.create({ name: 'findReplace', addProseMirrorPlugins() { return [ new Plugin({ key: pluginKey, state: { init(): DecorationState { return { decorations: DecorationSet.empty, currentIndex: -1 } }, apply(tr, old: DecorationState): DecorationState { const meta = tr.getMeta(pluginKey) if (meta) return meta if (tr.docChanged) { return { decorations: DecorationSet.empty, currentIndex: -1 } } return old || { decorations: DecorationSet.empty, currentIndex: -1 } }, }, props: { decorations(state) { return pluginKey.getState(state)?.decorations || null }, }, }), ] }, }) export function FindReplaceBar({ editor, onClose }: { editor: Editor; onClose: () => void }) { const { t } = useLanguage() const [searchText, setSearchText] = useState('') const [replaceText, setReplaceText] = useState('') const [caseSensitive, setCaseSensitive] = useState(false) const [useRegex, setUseRegex] = useState(false) const [count, setCount] = useState(0) const [currentIndex, setCurrentIndex] = useState(-1) const [showReplace, setShowReplace] = useState(false) const inputRef = useRef(null) const matchesRef = useRef([]) const indexRef = useRef(-1) const applyDecorations = useCallback((matches: Match[], activeIdx: number) => { const decos = matches.map((m, i) => Decoration.inline(m.from, m.to, { class: i === activeIdx ? 'fr-active' : 'fr-match', }) ) const set = DecorationSet.create(editor.state.doc, decos) const tr = editor.state.tr.setMeta(pluginKey, { decorations: set, currentIndex: activeIdx }) tr.setMeta('addToHistory', false) editor.view.dispatch(tr) }, [editor]) const scrollToMatch = useCallback((m: Match) => { try { const dom = editor.view.domAtPos(m.from) const el = dom.node instanceof HTMLElement ? dom.node : (dom.node as any).parentElement el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) } catch {} }, [editor]) const runSearch = useCallback((text: string) => { if (!text.trim()) { matchesRef.current = [] setCount(0) setCurrentIndex(-1) indexRef.current = -1 applyDecorations([], -1) return } const found = findMatches(editor.state.doc, text, caseSensitive, useRegex) matchesRef.current = found setCount(found.length) const idx = found.length > 0 ? 0 : -1 setCurrentIndex(idx) indexRef.current = idx applyDecorations(found, idx) if (idx >= 0) scrollToMatch(found[idx]) }, [caseSensitive, useRegex, editor, applyDecorations, scrollToMatch]) useEffect(() => { inputRef.current?.focus() }, []) const next = useCallback(() => { const ms = matchesRef.current if (ms.length === 0) return const ni = (indexRef.current + 1) % ms.length indexRef.current = ni setCurrentIndex(ni) applyDecorations(ms, ni) scrollToMatch(ms[ni]) }, [applyDecorations, scrollToMatch]) const prev = useCallback(() => { const ms = matchesRef.current if (ms.length === 0) return const ni = (indexRef.current - 1 + ms.length) % ms.length indexRef.current = ni setCurrentIndex(ni) applyDecorations(ms, ni) scrollToMatch(ms[ni]) }, [applyDecorations, scrollToMatch]) const replaceOne = useCallback(() => { const ms = matchesRef.current const idx = indexRef.current if (idx < 0 || ms.length === 0) return const m = ms[idx] editor.chain().focus().insertContentAt({ from: m.from, to: m.to }, replaceText).run() setTimeout(() => { const found = findMatches(editor.state.doc, searchText, caseSensitive, useRegex) matchesRef.current = found setCount(found.length) const ni = found.length > 0 ? Math.min(idx, found.length - 1) : -1 indexRef.current = ni setCurrentIndex(ni) applyDecorations(found, ni) if (ni >= 0) scrollToMatch(found[ni]) }, 50) }, [editor, replaceText, searchText, caseSensitive, useRegex, applyDecorations, scrollToMatch]) const replaceAll = useCallback(() => { const ms = matchesRef.current if (ms.length === 0) return // Sort descending by position so replacements don't shift earlier positions const sorted = [...ms].sort((a, b) => b.from - a.from) // Use a single ProseMirror transaction for all replacements const tr = editor.state.tr for (const m of sorted) { tr.insertText(replaceText, m.from, m.to) } editor.view.dispatch(tr) matchesRef.current = [] setCount(0) setCurrentIndex(-1) indexRef.current = -1 applyDecorations([], -1) }, [editor, replaceText, applyDecorations]) return createPortal(
e.stopPropagation()} >
{t('richTextEditor.findReplaceTitle')}
{ setSearchText(e.target.value) runSearch(e.target.value) }} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? prev() : next() } if (e.key === 'Escape') onClose() }} placeholder={t('richTextEditor.findPlaceholder')} className="flex-1 min-w-0 rounded-md border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30" /> {count > 0 ? `${currentIndex + 1}/${count}` : `0/0`}
{showReplace && (
setReplaceText(e.target.value)} onKeyDown={(e) => e.stopPropagation()} placeholder={t('richTextEditor.replacePlaceholder')} className="flex-1 min-w-0 rounded-md border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30" />
)}
, document.body ) }