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

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