import { useCallback, useEffect, useRef, useState } from 'react' import type { Editor } from '@tiptap/react' import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight' // FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the // SearchHighlight decoration layer: typing updates the highlighted matches, the // arrows step through them (scrolling each into view), and replace / replace-all // edit the document in place. Bilingual, zh-first, matching the editor chrome. interface Props { editor: Editor onClose: () => void } const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif" // Scroll the active match into the middle of the viewport without moving the // selection (which would otherwise pop the rewrite bubble). function scrollToActive(editor: Editor) { const st = getSearchState(editor.state) if (st.active < 0) return const m = st.matches[st.active] const dom = editor.view.domAtPos(m.from) const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : (dom.node as HTMLElement) el?.scrollIntoView({ block: 'center', behavior: 'smooth' }) } export function FindReplace({ editor, onClose }: Props) { const [query, setQuery] = useState('') const [replacement, setReplacement] = useState('') const [caseSensitive, setCaseSensitive] = useState(false) const [showReplace, setShowReplace] = useState(false) // Mirror of the plugin's match count + active index for the "n / m" readout. const [{ count, active }, setCounts] = useState({ count: 0, active: -1 }) const findRef = useRef(null) const sync = useCallback(() => { const st = getSearchState(editor.state) setCounts({ count: st.matches.length, active: st.active }) }, [editor]) // Push the query into the decoration layer whenever it (or case-sensitivity) // changes, then jump to the first match. useEffect(() => { setSearch(editor.state, editor.view.dispatch, query, caseSensitive) sync() scrollToActive(editor) }, [editor, query, caseSensitive, sync]) // Tear the highlight layer down when the bar closes. useEffect(() => () => clearSearch(editor.state, editor.view.dispatch), [editor]) // Focus the find field on open. useEffect(() => { findRef.current?.focus() findRef.current?.select() }, []) const go = useCallback( (delta: number) => { const st = getSearchState(editor.state) if (!st.matches.length) return setActive(editor.state, editor.view.dispatch, st.active + delta) sync() scrollToActive(editor) }, [editor, sync], ) const replaceActive = useCallback(() => { const st = getSearchState(editor.state) if (st.active < 0) return const m = st.matches[st.active] const tr = editor.state.tr if (replacement) tr.insertText(replacement, m.from, m.to) else tr.delete(m.from, m.to) editor.view.dispatch(tr) sync() scrollToActive(editor) }, [editor, replacement, sync]) const replaceAll = useCallback(() => { const st = getSearchState(editor.state) if (!st.matches.length) return const tr = editor.state.tr // Apply back-to-front so earlier edits don't shift later match positions. for (let i = st.matches.length - 1; i >= 0; i--) { const m = st.matches[i] if (replacement) tr.insertText(replacement, m.from, m.to) else tr.delete(m.from, m.to) } editor.view.dispatch(tr) sync() }, [editor, replacement, sync]) const inputStyle: React.CSSProperties = { borderRadius: 'var(--radius-input)', border: '1px solid var(--color-border)', color: 'var(--color-plum)', fontFamily: CJK, } return (
{ if (e.key === 'Escape') { e.preventDefault() onClose() } }} style={{ width: 320, background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-card)', boxShadow: 'var(--shadow-soft)', }} >
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() go(e.shiftKey ? -1 : 1) } }} placeholder="查找 · Find" className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none" style={inputStyle} /> {count ? `${active + 1} / ${count}` : query ? '无 · 0' : ''} go(-1)}>↑ go(1)}>↓ setCaseSensitive((v) => !v)} title="Match case · 区分大小写" > Aa
{showReplace && (
setReplacement(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() replaceActive() } }} placeholder="替换为 · Replace" className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none" style={inputStyle} />
)}
) } function FindBtn({ children, onClick, label, title, active, disabled, }: { children: React.ReactNode onClick: () => void label: string title?: string active?: boolean disabled?: boolean }) { return ( ) }