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:
prosolis
2026-06-26 09:47:26 -07:00
parent 6e6e4edce7
commit db737fa612
20 changed files with 1039 additions and 22 deletions

View File

@@ -192,6 +192,38 @@ export default function App() {
setDocText('')
}, [saveNow, isBlankDraft])
// Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)",
// then open the copy. Tags aren't carried over (a fresh start for the copy).
const handleDuplicate = useCallback(
async (id: string) => {
try {
await saveNow() // flush in case we're duplicating the open doc
const src = await api.getDoc(id)
const fresh = await api.createDoc()
const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)`
const updated = await api.updateDoc(fresh.id, {
title: dupTitle,
content: src.content,
content_text: src.content_text,
tone: src.tone,
word_count: src.word_count,
})
setDocs((prev) => [
{ id: updated.id, title: updated.title, word_count: updated.word_count, updated_at: updated.updated_at, tags: [] },
...prev,
])
setCurrentDoc(updated)
setTitle(updated.title)
setWordCount(updated.word_count)
setTone(updated.tone || 'general')
setDocText(updated.content_text)
} catch (err) {
console.error('duplicate failed', err)
}
},
[saveNow],
)
const handleDelete = useCallback(
async (id: string) => {
await api.deleteDoc(id)
@@ -371,6 +403,7 @@ export default function App() {
onSelect={openDoc}
onCreate={handleCreate}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onToggleTag={handleToggleTag}
onCreateTag={handleCreateTag}
/>

View File

@@ -65,6 +65,7 @@ export interface WordMeaning {
export interface WordInfo {
word: string
gloss: string // Chinese translation; '' when the word isn't in the gloss set
phonetic: string // IPA for the English word; '' when absent
definitions: WordMeaning[]
synonyms: string[]
}
@@ -164,6 +165,10 @@ export const api = {
exportUrl: (id: string, format: ExportFormat) =>
`/api/docs/${id}/export?format=${format}`,
// Download URL for a whole-corpus backup: a zip of every document rendered in
// the given format. A one-click "download all my writing" safety net.
exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`,
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant

32
web/src/audio/speech.ts Normal file
View File

@@ -0,0 +1,32 @@
// Read-aloud via the browser's Web Speech API — an offline pronunciation aid for
// an ESL writer: hear how an English word or passage sounds. No model, no
// network, no extra weight in the binary. Feature-detected so the buttons that
// use it can hide where speech isn't available.
export function speechSupported(): boolean {
return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
}
// pickVoice prefers an exact locale match (e.g. en-US), then any voice in the
// same language family (en-*). Returns undefined to let the engine default.
function pickVoice(lang: string): SpeechSynthesisVoice | undefined {
const voices = window.speechSynthesis.getVoices()
const base = lang.split('-')[0]
return voices.find((v) => v.lang === lang) || voices.find((v) => v.lang?.startsWith(base))
}
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
// don't queue up. `lang` defaults to US English (the language she's learning);
// pass a zh locale to hear a Chinese passage. A touch slower than default so
// learners can follow along.
export function speak(text: string, lang = 'en-US'): void {
if (!speechSupported() || !text.trim()) return
const synth = window.speechSynthesis
synth.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang
const voice = pickVoice(lang)
if (voice) utterance.voice = voice
utterance.rate = 0.95
synth.speak(utterance)
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import type { DocSummary, Tag, TagColor } from '../../api/client'
import { api, type DocSummary, type Tag, type TagColor } from '../../api/client'
import { DocListItem } from './DocListItem'
import { SearchBox } from './SearchBox'
import { TagChip } from './TagChip'
@@ -11,10 +11,19 @@ interface Props {
onSelect: (id: string) => void
onCreate: () => void
onDelete: (id: string) => void
onDuplicate: (id: string) => void
onToggleTag: (docId: string, tag: Tag) => void
onCreateTag: (docId: string, name: string, color: TagColor) => void
}
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
type SortMode = 'recent' | 'title' | 'longest'
const SORTS: { value: SortMode; label: string }[] = [
{ value: 'recent', label: '最近 · Recent' },
{ value: 'title', label: '标题 · Title' },
{ value: 'longest', label: '字数 · Longest' },
]
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
// button, and the document browser (each row showing its tag chips).
export function DocList({
@@ -24,6 +33,7 @@ export function DocList({
onSelect,
onCreate,
onDelete,
onDuplicate,
onToggleTag,
onCreateTag,
}: Props) {
@@ -31,11 +41,21 @@ export function DocList({
// disappears from the roster.
const [filterId, setFilterId] = useState<string | null>(null)
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
const [sort, setSort] = useState<SortMode>('recent')
const filtered = useMemo(
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
[docs, activeFilter],
)
const filtered = useMemo(() => {
const base = activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs
if (sort === 'recent') return base // already updated_at-desc from the server
const arr = [...base]
if (sort === 'title') {
arr.sort((a, b) =>
(a.title || 'Untitled').localeCompare(b.title || 'Untitled', undefined, { sensitivity: 'base' }),
)
} else {
arr.sort((a, b) => b.word_count - a.word_count)
}
return arr
}, [docs, activeFilter, sort])
// Only surface tags that are actually in use, so the filter bar stays tidy.
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
@@ -72,6 +92,25 @@ export function DocList({
<span className="text-base leading-none"></span> New Doc
</button>
{docs.length > 1 && (
<div className="flex items-center justify-end gap-1.5 px-1 text-xs" style={{ color: 'var(--color-muted)' }}>
<span aria-hidden></span>
<select
aria-label="Sort documents"
value={sort}
onChange={(e) => setSort(e.target.value as SortMode)}
className="bg-transparent text-xs font-semibold focus:outline-none"
style={{ color: 'var(--color-plum)' }}
>
{SORTS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
)}
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
@@ -86,12 +125,30 @@ export function DocList({
roster={roster}
onSelect={() => onSelect(doc.id)}
onDelete={() => onDelete(doc.id)}
onDuplicate={() => onDuplicate(doc.id)}
onToggleTag={onToggleTag}
onCreateTag={onCreateTag}
/>
))
)}
</div>
{/* Whole-corpus backup — "download all my writing". Plain download links so
the browser saves the zip; no extra app state needed. */}
{docs.length > 0 && (
<div
className="flex items-center gap-2 px-1 pt-1 text-xs"
style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }}
>
<span className="font-semibold"> · Back up all:</span>
<a href={api.exportAllUrl('docx')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
Word
</a>
<a href={api.exportAllUrl('md')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
Markdown
</a>
</div>
)}
</aside>
)
}

View File

@@ -9,6 +9,7 @@ interface Props {
roster: Tag[]
onSelect: () => void
onDelete: () => void
onDuplicate: () => void
onToggleTag: (docId: string, tag: Tag) => void
onCreateTag: (docId: string, name: string, color: TagColor) => void
}
@@ -21,6 +22,7 @@ export function DocListItem({
roster,
onSelect,
onDelete,
onDuplicate,
onToggleTag,
onCreateTag,
}: Props) {
@@ -65,6 +67,19 @@ export function DocListItem({
>
🏷
</button>
<button
type="button"
aria-label="Duplicate document"
title="副本 · Duplicate"
onClick={(e) => {
e.stopPropagation()
onDuplicate()
}}
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
</button>
<button
type="button"
aria-label="Delete document"

View File

@@ -24,8 +24,12 @@ import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard'
import { GlossTip } from './GlossTip'
import { SelectionBubble } from './SelectionBubble'
import { SearchHighlight } from './SearchHighlight'
import { FindReplace } from './FindReplace'
import { Typography } from './Typography'
import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import type { SpellChecker } from '../../hooks/useSpellChecker'
export interface EditorChange {
@@ -207,8 +211,14 @@ export function EditorCore({
// The rewrite affordances: a bubble over the current selection, and the
// preview that replaces it once a style is chosen.
const [selection, setSelection] = useState<SelectionState | null>(null)
// True while a pointer is held down (a drag-select in progress). The rewrite
// bubble stays hidden until release so it never appears — or re-anchors — under
// a still-moving cursor, which is where the user is trying to click.
const [dragging, setDragging] = useState(false)
const [rewrite, setRewrite] = useState<RewriteState | null>(null)
const rewriteReqRef = useRef(0)
// The in-document Find & Replace bar (Ctrl/Cmd+F).
const [findOpen, setFindOpen] = useState(false)
const editor = useEditor({
extensions: [
@@ -229,6 +239,8 @@ export function EditorCore({
CharacterCount,
SuggestionHighlight,
SpellCheck,
SearchHighlight,
Typography,
],
content: parseDoc(initialContent),
editorProps: {
@@ -467,20 +479,17 @@ export function EditorCore({
setMisspell(null)
}, [misspell, onAddWord])
// Right-click a word to look it up: resolve the exact word span under the
// pointer, anchor a popover beneath it, and kick off the offline lookup. The
// card opens immediately in a loading state and fills in when the (local)
// lookup returns. Right-clicking off any word falls through to the native menu.
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
// openWordLookup resolves the exact word span at a document position, anchors a
// popover beneath it, and kicks off the offline lookup. The card opens
// immediately in a loading state and fills in when the (local) lookup returns.
// Shared by right-click, the keyboard shortcut (caret), and touch long-press.
const openWordLookup = useCallback(
(pos: number) => {
if (!editor) return
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) return
const range = wordAt(editor.state.doc, coords.pos)
const range = wordAt(editor.state.doc, pos)
if (!range) return
const wrapper = wrapperRef.current
if (!wrapper) return
e.preventDefault()
// Anchor under the word itself (not the click point) so the card lines up
// with the text the way the spelling popover does.
const start = editor.view.coordsAtPos(range.from)
@@ -511,6 +520,37 @@ export function EditorCore({
[editor, closeCard],
)
// Right-click a word to look it up. Right-clicking off any word falls through
// to the native menu (so copy/paste-by-menu still works — see the Selection fix).
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!editor) return
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) return
if (!wordAt(editor.state.doc, coords.pos)) return
e.preventDefault()
openWordLookup(coords.pos)
},
[editor, openWordLookup],
)
// Touch has no hover or right-click, so a long-press (~500ms without moving)
// opens the word lookup at the finger — the touch equivalent of right-click.
const longPressTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (!editor || e.touches.length !== 1) return
const { clientX, clientY } = e.touches[0]
clearTimeout(longPressTimer.current)
longPressTimer.current = setTimeout(() => {
const coords = editor.view.posAtCoords({ left: clientX, top: clientY })
if (coords) openWordLookup(coords.pos)
}, 500)
},
[editor, openWordLookup],
)
const cancelLongPress = useCallback(() => clearTimeout(longPressTimer.current), [])
const replaceWord = useCallback(
(synonym: string) => {
if (editor && wordInfo) {
@@ -683,10 +723,52 @@ export function EditorCore({
return () => document.removeEventListener('mousedown', onDown)
}, [misspell])
// A pointer-down in the editor starts a (possible) drag-select, which keeps the
// rewrite bubble hidden until release. A pointer-down on the bubble itself is
// excluded so its buttons stay clickable. Release always re-arms the bubble.
const handlePointerDown = useCallback((e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest('.petal-selection-bubble')) return
setDragging(true)
}, [])
useEffect(() => {
const onUp = () => setDragging(false)
document.addEventListener('pointerup', onUp)
return () => document.removeEventListener('pointerup', onUp)
}, [])
// Editor keyboard shortcuts, so the ESL helpers aren't mouse-only:
// Ctrl/Cmd+F — Find & Replace (overrides the browser find, which can't see
// into the editor's content anyway)
// Ctrl/Cmd+D — look up the word at the caret (the keyboard "right-click")
// Ctrl/Cmd+J — rewrite the current selection more naturally (✨更自然)
useEffect(() => {
if (!editor) return
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.altKey) return
const k = e.key.toLowerCase()
if (k === 'f') {
e.preventDefault()
setFindOpen(true)
} else if (k === 'd') {
e.preventDefault()
openWordLookup(editor.state.selection.from)
} else if (k === 'j') {
if (!editor.state.selection.empty) {
e.preventDefault()
handleRewrite('natural')
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [editor, openWordLookup, handleRewrite])
useEffect(() => () => {
clearTimeout(closeTimer.current)
clearTimeout(confettiTimer.current)
clearTimeout(glossTimer.current)
clearTimeout(longPressTimer.current)
}, [])
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
@@ -724,16 +806,22 @@ export function EditorCore({
onMouseOut={handleMouseOut}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onPointerDown={handlePointerDown}
onClick={handleSpellClick}
onContextMenu={handleContextMenu}
onTouchStart={handleTouchStart}
onTouchMove={cancelLongPress}
onTouchEnd={cancelLongPress}
>
<EditorContent editor={editor} className="h-full" />
{findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
{selection && !rewrite && (
{selection && !rewrite && !dragging && (
<SelectionBubble
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
onRewrite={handleRewrite}
onSpeak={speechSupported() ? () => speak(selection.text) : null}
/>
)}
{rewrite && (

View 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>
)
}

View File

@@ -0,0 +1,143 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { Node as PMNode } from '@tiptap/pm/model'
import { mapOffset } from './SuggestionHighlight'
// SearchHighlight powers the in-document Find & Replace bar. Like the suggestion
// layer it uses ProseMirror *decorations* (not stored marks), so matches are
// recomputed from the live document on every edit and never leave a stale
// highlight behind. Matches are found per-textblock (a query never spans a
// paragraph break), mirroring how the rest of the editor anchors text.
export interface Match {
from: number
to: number
}
interface PluginState {
query: string
caseSensitive: boolean
matches: Match[]
active: number // index into matches, or -1 when there are none
decorations: DecorationSet
}
export const searchPluginKey = new PluginKey<PluginState>('petalSearch')
// findMatches collects every occurrence of `query` across the document's
// textblocks and maps each to an absolute ProseMirror range.
function findMatches(doc: PMNode, query: string, caseSensitive: boolean): Match[] {
if (!query) return []
const needle = caseSensitive ? query : query.toLowerCase()
const matches: Match[] = []
doc.descendants((node, pos) => {
if (!node.isTextblock) return true
const haystackRaw = node.textContent
const haystack = caseSensitive ? haystackRaw : haystackRaw.toLowerCase()
let idx = haystack.indexOf(needle)
while (idx >= 0) {
matches.push({
from: mapOffset(node, pos, idx),
to: mapOffset(node, pos, idx + query.length),
})
idx = haystack.indexOf(needle, idx + query.length)
}
return false // don't descend into inline children
})
return matches
}
function build(doc: PMNode, query: string, caseSensitive: boolean, preferred: number): PluginState {
const matches = findMatches(doc, query, caseSensitive)
const active = matches.length === 0 ? -1 : Math.max(0, Math.min(preferred, matches.length - 1))
const decos = matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
}),
)
return { query, caseSensitive, matches, active, decorations: DecorationSet.create(doc, decos) }
}
const EMPTY: PluginState = {
query: '',
caseSensitive: false,
matches: [],
active: -1,
decorations: DecorationSet.empty,
}
// setSearch updates the query / case-sensitivity and recomputes matches. Passing
// an empty query clears the layer.
export function setSearch(
state: EditorState,
dispatch: (tr: Transaction) => void,
query: string,
caseSensitive: boolean,
) {
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'search', query, caseSensitive }))
}
// setActive moves the highlighted (current) match by index.
export function setActive(state: EditorState, dispatch: (tr: Transaction) => void, index: number) {
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'active', index }))
}
// clearSearch tears the layer down (on close).
export function clearSearch(state: EditorState, dispatch: (tr: Transaction) => void) {
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'clear' }))
}
// getSearchState reads the live plugin state (match list + active index) so the
// Find bar can render "n / m" and drive navigation/replacement.
export function getSearchState(state: EditorState): PluginState {
return searchPluginKey.getState(state) ?? EMPTY
}
type Meta =
| { kind: 'search'; query: string; caseSensitive: boolean }
| { kind: 'active'; index: number }
| { kind: 'clear' }
export const SearchHighlight = Extension.create({
name: 'searchHighlight',
addProseMirrorPlugins() {
return [
new Plugin<PluginState>({
key: searchPluginKey,
state: {
init: () => EMPTY,
apply(tr, value, _oldState, newState): PluginState {
const meta = tr.getMeta(searchPluginKey) as Meta | undefined
if (meta?.kind === 'search') {
return build(newState.doc, meta.query, meta.caseSensitive, value.active < 0 ? 0 : value.active)
}
if (meta?.kind === 'active') {
if (value.matches.length === 0) return value
const active = ((meta.index % value.matches.length) + value.matches.length) % value.matches.length
const decos = value.matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
}),
)
return { ...value, active, decorations: DecorationSet.create(newState.doc, decos) }
}
if (meta?.kind === 'clear') return EMPTY
// Re-anchor on any document change so highlights track edits/replaces.
if (tr.docChanged && value.query) {
return build(newState.doc, value.query, value.caseSensitive, value.active)
}
return value
},
},
props: {
decorations(state) {
return searchPluginKey.getState(state)?.decorations
},
},
}),
]
},
})

View File

@@ -27,11 +27,14 @@ export const REWRITE_STYLES: RewriteStyle[] = [
interface Props {
style: React.CSSProperties
onRewrite: (style: string) => void
// Read the selected text aloud (null when speech isn't available — the button
// is then hidden).
onSpeak: (() => void) | null
}
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
export function SelectionBubble({ style, onRewrite }: Props) {
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
const [natural, ...tones] = REWRITE_STYLES
return (
@@ -39,21 +42,25 @@ export function SelectionBubble({ style, onRewrite }: Props) {
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
role="toolbar"
aria-label="Rewrite the selection"
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-pill)',
boxShadow: 'var(--shadow-soft)',
fontFamily: CJK,
// Only the buttons capture the pointer; clicks landing on the bubble's
// padding/gaps fall through to the text underneath so it never blocks
// where the user is trying to click.
pointerEvents: 'none',
...style,
}}
>
<button
type="button"
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
onClick={() => onRewrite(natural.value)}
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)' }}
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
title="Rewrite the selection to sound more natural"
>
<span aria-hidden>{natural.emoji}</span>
@@ -63,15 +70,30 @@ export function SelectionBubble({ style, onRewrite }: Props) {
</span>
</button>
{onSpeak && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
onClick={onSpeak}
className="inline-flex h-8 items-center justify-center px-2 text-sm"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
title="朗读所选 · Read selection aloud"
aria-label="Read selection aloud"
>
🔊
</button>
)}
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
{tones.map((t) => (
<button
key={t.value}
type="button"
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
onClick={() => onRewrite(t.value)}
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
title={`Rewrite in a ${t.en.toLowerCase()} tone`}

View File

@@ -0,0 +1,29 @@
import { Extension, textInputRule } from '@tiptap/core'
// Typography adds the small print-shop niceties as you type: curly quotes,
// em-dashes, and a single-character ellipsis. Implemented as input rules (no
// dependency, offline-friendly — matching FontSize.ts's hand-rolled approach),
// and every one is plain Undo-able if it ever fires when you didn't want it.
//
// CJK is untouched: these rules only rewrite ASCII quotes/dashes/dots, never the
// fullwidth forms a Mandarin sentence uses.
export const Typography = Extension.create({
name: 'petalTypography',
addInputRules() {
return [
// “ opening double quote — after start, whitespace, or an opening bracket.
textInputRule({ find: /(?:^|[\s{[(<'"‘“])(")$/, replace: '“' }),
// ” closing double quote — any other time you type a ".
textInputRule({ find: /"$/, replace: '”' }),
// opening single quote.
textInputRule({ find: /(?:^|[\s{[(<'"‘“])(')$/, replace: '' }),
// closing single quote / apostrophe.
textInputRule({ find: /'$/, replace: '' }),
// — em dash from a double hyphen.
textInputRule({ find: /--$/, replace: '—' }),
// … ellipsis from three dots.
textInputRule({ find: /\.\.\.$/, replace: '…' }),
]
},
})

View File

@@ -1,4 +1,5 @@
import type { WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
// WordCard is the right-click popover for any word: its dictionary definition(s)
// on top and tappable synonym pills below. Clicking a synonym replaces the word
@@ -18,6 +19,7 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
const definitions = info?.definitions ?? []
const synonyms = info?.synonyms ?? []
const gloss = info?.gloss ?? ''
const phonetic = info?.phonetic ?? ''
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
return (
@@ -46,8 +48,28 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
{word}
</span>
{speechSupported() && (
<button
type="button"
onClick={() => speak(word)}
aria-label={`Pronounce ${word}`}
title="朗读 · Read aloud"
className="ml-auto flex h-7 w-7 items-center justify-center rounded-full text-sm"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
🔊
</button>
)}
</div>
{/* How to say it — the pronunciation aid for an English learner, paired
with the 🔊 button above. */}
{phonetic && (
<p className="mt-1.5 text-sm" style={{ color: 'var(--color-muted)' }}>
/{phonetic}/
</p>
)}
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
{gloss && (
<p

View File

@@ -167,7 +167,7 @@ function Swatch({
// the current selection without re-rendering the whole tree on every keystroke.
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
// Which popover (if any) is open. Only one at a time.
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | null>(null)
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -222,6 +222,26 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
close()
}
// Collect the document's headings (with their positions) for the outline. Read
// fresh each time the popover opens, so it always reflects the current doc.
const headings: { level: number; text: string; pos: number }[] = []
if (menu === 'outline') {
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
headings.push({ level: (node.attrs.level as number) || 1, text: node.textContent || '(无标题)', pos })
}
})
}
// Scroll a heading into view without moving the selection (which would pop the
// rewrite bubble). domAtPos resolves the heading's DOM node to scroll to.
const gotoHeading = (pos: number) => {
const dom = editor.view.domAtPos(pos + 1)
const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : (dom.node as HTMLElement)
el?.scrollIntoView({ block: 'start', behavior: 'smooth' })
close()
}
const onPickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) uploadImageInto(editor.view, file)
@@ -507,6 +527,50 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
/>
)}
</Popover>
{/* Outline / document map */}
<Popover
open={menu === 'outline'}
onClose={close}
width={240}
trigger={
<TBtn label="Outline" active={menu === 'outline'} onClick={() => setMenu(menu === 'outline' ? null : 'outline')}>
</TBtn>
}
>
<div className="max-h-72 overflow-y-auto">
<p className="mb-1.5 px-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Outline
</p>
{headings.length === 0 ? (
<p className="px-1 py-2 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
H1/H2/H3 <br />
Add headings to navigate them here.
</p>
) : (
<div className="flex flex-col">
{headings.map((h, i) => (
<button
key={i}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => gotoHeading(h.pos)}
className="truncate rounded px-1.5 py-1 text-left text-sm hover:bg-[var(--color-surface-alt)]"
style={{
paddingLeft: `${(h.level - 1) * 12 + 6}px`,
color: 'var(--color-plum)',
fontWeight: h.level === 1 ? 700 : 500,
}}
title={h.text}
>
{h.text}
</button>
))}
</div>
)}
</div>
</Popover>
<Divider />
<button

View File

@@ -223,6 +223,20 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both;
}
/* --- Find & Replace ---------------------------------------------------------
In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the
current match is brighter with a rose ring so it stands out as you step
through. Decorations, like every other highlight layer here. */
.petal-find-match {
background: var(--color-highlight, #fff1a8);
border-radius: 3px;
box-shadow: 0 0 0 1px var(--color-border);
}
.petal-find-match-active {
background: var(--color-peach);
box-shadow: 0 0 0 2px var(--color-accent);
}
/* --- Spell check ------------------------------------------------------------
Browser-side nspell flags misspellings with a soft rose wavy underline (a
gentler take on the classic red squiggle, to fit the pastel palette). Like the