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
239 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|