Files
petal/web/src/components/Editor/FindReplace.tsx
prosolis db737fa612 Editor: Find & Replace, read-aloud, backup, typography, phonetic, org niceties
Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix.

- Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension +
  FindReplace bar (match-case, replace-all back-to-front, scroll without
  popping the selection bubble).
- Read-aloud (Web Speech, offline) on the word card and selection bubble.
- Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret,
  Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the
  right-click handler into a shared openWordLookup(pos).
- Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx),
  de-dupes filenames, dated name; sidebar download links. TestExportAll.
- Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so
  CJK is untouched.
- Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover.
- English phonetic (chosen over pinyin for an English learner): ECDICT-built
  phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line;
  scripts/build_phonetic.py (full build + --seed fallback).
- Selection bubble no longer blocks copy/paste: deferred to pointer-up and made
  click-through except on its buttons.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:47:26 -07:00

239 lines
7.8 KiB
TypeScript

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<HTMLInputElement>(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 (
<div
className="petal-no-print absolute right-2 top-2 z-40 flex flex-col gap-1.5 p-2"
role="dialog"
aria-label="Find and replace"
onKeyDown={(e) => {
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)',
}}
>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setShowReplace((v) => !v)}
aria-label={showReplace ? 'Hide replace' : 'Show replace'}
title={showReplace ? 'Hide replace' : 'Show replace'}
className="flex h-8 w-6 items-center justify-center text-xs"
style={{ color: 'var(--color-muted)' }}
>
{showReplace ? '▾' : '▸'}
</button>
<input
ref={findRef}
value={query}
onChange={(e) => 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}
/>
<span
className="w-14 shrink-0 text-center text-xs tabular-nums"
style={{ color: 'var(--color-muted)' }}
>
{count ? `${active + 1} / ${count}` : query ? '无 · 0' : ''}
</span>
<FindBtn label="Previous match" disabled={!count} onClick={() => go(-1)}></FindBtn>
<FindBtn label="Next match" disabled={!count} onClick={() => go(1)}></FindBtn>
<FindBtn
label="Match case"
active={caseSensitive}
onClick={() => setCaseSensitive((v) => !v)}
title="Match case · 区分大小写"
>
Aa
</FindBtn>
<FindBtn label="Close" onClick={onClose} title="Close · 关闭"></FindBtn>
</div>
{showReplace && (
<div className="flex items-center gap-1.5 pl-7">
<input
value={replacement}
onChange={(e) => 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}
/>
<button
type="button"
onClick={replaceActive}
disabled={!count}
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-semibold disabled:opacity-40"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
</button>
<button
type="button"
onClick={replaceAll}
disabled={!count}
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-bold disabled:opacity-40"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: '#fff' }}
>
</button>
</div>
)}
</div>
)
}
function FindBtn({
children,
onClick,
label,
title,
active,
disabled,
}: {
children: React.ReactNode
onClick: () => void
label: string
title?: string
active?: boolean
disabled?: boolean
}) {
return (
<button
type="button"
aria-label={label}
title={title ?? label}
aria-pressed={active}
disabled={disabled}
onClick={onClick}
className="flex h-8 min-w-8 items-center justify-center px-1.5 text-sm font-semibold disabled:opacity-40"
style={{
borderRadius: 'var(--radius-input)',
color: active ? 'var(--color-accent-hover)' : 'var(--color-muted)',
background: active ? 'var(--color-surface-alt)' : 'transparent',
}}
>
{children}
</button>
)
}