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
This commit is contained in:
238
web/src/components/Editor/FindReplace.tsx
Normal file
238
web/src/components/Editor/FindReplace.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user