Merge feat/writer-power-ups: editor power-ups (find/replace, read-aloud, backup, phonetic)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -95,6 +95,16 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
|
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
|
||||||
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
|
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
|
||||||
|
|
||||||
|
### Phase 11 — Writer power-ups ✅
|
||||||
|
- [x] **In-document Find & Replace (Ctrl/Cmd+F)** — `SearchHighlight` ProseMirror extension (decorations, not marks — same anchoring discipline as the suggestion/spell layers; matches recomputed per-textblock on every edit, never stranded). `FindReplace` bar (bilingual zh-first): live match highlighting, ↑/↓ step-through with no-selection DOM scroll-into-view (so it never pops the rewrite bubble), match-case toggle, replace / replace-all (replace-all applies back-to-front so earlier edits don't shift later positions). Honey wash on all matches, rose ring on the active one.
|
||||||
|
- [x] **Read-aloud / TTS** (`web/src/audio/speech.ts`) — Web Speech API, offline, feature-detected. 🔊 in the `WordCard` (pronounce the word) and the selection bubble (read the selection). Pairs with the phonetic line.
|
||||||
|
- [x] **Keyboard + touch access to the ESL helpers** — refactored the right-click word-lookup into a position-based `openWordLookup(pos)`; now also driven by **Ctrl/Cmd+D** (look up the word at the caret) and a **touch long-press** (~500ms, the touch equivalent of right-click). **Ctrl/Cmd+J** rewrites the selection more naturally. (Closes the "pointer-only" limitation noted in Phase 10.)
|
||||||
|
- [x] **Whole-corpus backup** — `GET /api/docs/export-all?format=md|docx|…` zips every doc (reuses the per-doc renderers; de-duplicates same-titled filenames; dated `petal-backup-YYYY-MM-DD.zip`). Static route takes priority over `/{id}` in chi — covered by `TestExportAll`. Sidebar footer "备份 · Back up all: Word / Markdown" download links.
|
||||||
|
- [x] **Smart typography** (`Typography.ts`) — dependency-free input rules: curly quotes, em-dash (`--`), ellipsis (`...`). ASCII-only triggers so CJK fullwidth punctuation is untouched; every rule is plain-Undo-able.
|
||||||
|
- [x] **Org niceties** — duplicate-a-document (App `handleDuplicate` → "… (副本)", copies body/tone, not tags), sidebar **sort** (Recent / Title / Longest), and a **document outline** popover in the toolbar (headings → click to scroll, indented by level).
|
||||||
|
- [x] **English phonetic (pivot from pinyin)** — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently). `scripts/build_phonetic.py` extracts ECDICT's `phonetic` column (same source/freq-gate as the gloss); `phonetic.json.gz` embedded + lazily loaded; `Result.Phonetic` resolved via the same de-inflecting candidate walk; shown as `/ˈrɪvər/` in the WordCard. **Full dataset built from ECDICT: 46,579 words (361KB gz)**, in line with the other lexicon assets. The script also has a `--seed` mode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist.
|
||||||
|
- Verified: go build/vet/test (incl. new `TestExportAll`), tsc, vite build all clean; live smoke vs throwaway binaries — `/word/river|running|serendipity|rivers` all return phonetic (`rivers`→`river` de-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with `/{id}`.
|
||||||
|
|
||||||
### Deferred (post-v1-local)
|
### Deferred (post-v1-local)
|
||||||
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
||||||
- [ ] Copyleaks Tier-2 + webhook HMAC
|
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||||
@@ -105,6 +115,7 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
||||||
|
|
||||||
## Session log
|
## Session log
|
||||||
|
- 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace** — `SearchHighlight` decoration extension + `FindReplace` bar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) **read-aloud** Web Speech util + 🔊 in WordCard & selection bubble; (3) **keyboard/touch access** — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactored `handleContextMenu` → shared `openWordLookup(pos)`); (4) **export-all** backup zip (`GET /api/docs/export-all`, `TestExportAll`, sidebar download links); (5) **smart typography** input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) **duplicate doc + sidebar sort + outline popover**; (7) **English phonetic** (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) via `scripts/build_phonetic.py` + embedded `phonetic.json.gz` + `Result.Phonetic` + WordCard `/ˈrɪvər/` line — **full 46,579-word dataset built from ECDICT** (the csv re-download worked; `--seed` mode kept as a csv-free fallback). Also folded in this session: the **selection-bubble vs copy/paste fix** (bubble deferred to pointer-up + container `pointer-events:none` so it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user.
|
||||||
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
||||||
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
|
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
|
||||||
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
|
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
|
||||||
|
|||||||
@@ -9,15 +9,101 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// exportRoutes registers the download endpoint: GET /api/docs/{id}/export?format=md|html|txt|docx
|
// exportRoutes registers the download endpoints: one document
|
||||||
|
// (GET /api/docs/{id}/export?format=…) and a whole-corpus backup zip
|
||||||
|
// (GET /api/docs/export-all?format=…). The static "export-all" segment takes
|
||||||
|
// priority over the {id} param in chi's router, so the two don't collide.
|
||||||
func (h *Handler) exportRoutes(r chi.Router) {
|
func (h *Handler) exportRoutes(r chi.Router) {
|
||||||
r.Get("/{id}/export", h.export)
|
r.Get("/{id}/export", h.export)
|
||||||
|
r.Get("/export-all", h.exportAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportAll streams every document the user owns, each rendered in the requested
|
||||||
|
// format, bundled into a single zip — a one-click "download all my writing"
|
||||||
|
// backup. Per-doc version history guards against bad edits; this guards against
|
||||||
|
// a lost disk. Reuses the same renderers as the single-document export.
|
||||||
|
func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
formatKey := r.URL.Query().Get("format")
|
||||||
|
if formatKey == "" {
|
||||||
|
formatKey = "md"
|
||||||
|
}
|
||||||
|
format, ok := exportFormats[formatKey]
|
||||||
|
if !ok {
|
||||||
|
badRequest(w, "unsupported export format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(
|
||||||
|
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
||||||
|
FROM documents
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY updated_at DESC`,
|
||||||
|
db.LocalUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
seen := map[string]int{} // de-duplicate filenames from same-titled docs
|
||||||
|
for rows.Next() {
|
||||||
|
var doc db.Document
|
||||||
|
if err := rows.Scan(
|
||||||
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := format.render(doc)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := sanitizeFilename(doc.Title)
|
||||||
|
if base == "" {
|
||||||
|
base = "untitled"
|
||||||
|
}
|
||||||
|
key := base + "." + format.ext
|
||||||
|
name := key
|
||||||
|
if c := seen[key]; c > 0 {
|
||||||
|
name = fmt.Sprintf("%s (%d).%s", base, c, format.ext)
|
||||||
|
}
|
||||||
|
seen[key]++
|
||||||
|
f, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := f.Write(body); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := "petal-backup-" + time.Now().Format("2006-01-02") + ".zip"
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
// exportFormat describes one downloadable format: how to render it and how to
|
// exportFormat describes one downloadable format: how to render it and how to
|
||||||
|
|||||||
@@ -61,6 +61,45 @@ func TestExportMarkdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportAll(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
// Two docs, one with a duplicate title to exercise filename de-duplication.
|
||||||
|
seedRichDoc(t, srv)
|
||||||
|
id2 := newDoc(t, srv)
|
||||||
|
if rec := do(t, srv, http.MethodPut, "/"+id2, `{"title":"日记 Diary","content":`+jsonString(richDocJSON)+`,"content_text":"x","word_count":1}`); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("seed second doc: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodGet, "/export-all?format=md", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("export-all: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
|
||||||
|
t.Fatalf("unexpected content-type: %q", ct)
|
||||||
|
}
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip open: %v", err)
|
||||||
|
}
|
||||||
|
if len(zr.File) != 2 {
|
||||||
|
t.Fatalf("expected 2 files in backup, got %d", len(zr.File))
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, f := range zr.File {
|
||||||
|
names[f.Name] = true
|
||||||
|
rc, _ := f.Open()
|
||||||
|
b, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if !strings.Contains(string(b), "你好") {
|
||||||
|
t.Fatalf("backup entry %q missing CJK body", f.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The duplicate title must have been disambiguated, not overwritten.
|
||||||
|
if !names["日记 Diary.md"] || !names["日记 Diary (1).md"] {
|
||||||
|
t.Fatalf("expected de-duplicated filenames, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportHTML(t *testing.T) {
|
func TestExportHTML(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
id := seedRichDoc(t, srv)
|
id := seedRichDoc(t, srv)
|
||||||
|
|||||||
@@ -27,3 +27,12 @@ var synonymsGz []byte
|
|||||||
//
|
//
|
||||||
//go:embed data/gloss.json.gz
|
//go:embed data/gloss.json.gz
|
||||||
var glossGz []byte
|
var glossGz []byte
|
||||||
|
|
||||||
|
// phoneticGz is the gzipped English-phonetic map: word → IPA, lowercase keys.
|
||||||
|
// Built from ECDICT's phonetic column (scripts/build_phonetic.py). Shown beside
|
||||||
|
// the read-aloud button so the writer — a Mandarin speaker learning English —
|
||||||
|
// can see how to say the word. Ships with a small common-word seed; a full build
|
||||||
|
// broadens coverage.
|
||||||
|
//
|
||||||
|
//go:embed data/phonetic.json.gz
|
||||||
|
var phoneticGz []byte
|
||||||
|
|||||||
BIN
internal/lexicon/data/phonetic.json.gz
Normal file
BIN
internal/lexicon/data/phonetic.json.gz
Normal file
Binary file not shown.
@@ -26,6 +26,7 @@ type Meaning struct {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Word string `json:"word"`
|
Word string `json:"word"`
|
||||||
Gloss string `json:"gloss"`
|
Gloss string `json:"gloss"`
|
||||||
|
Phonetic string `json:"phonetic"` // IPA for the English word; "" when absent
|
||||||
Definitions []Meaning `json:"definitions"`
|
Definitions []Meaning `json:"definitions"`
|
||||||
Synonyms []string `json:"synonyms"`
|
Synonyms []string `json:"synonyms"`
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,7 @@ type Lexicon struct {
|
|||||||
defs map[string][][]string // word → [[pos, def, example], …]
|
defs map[string][][]string // word → [[pos, def, example], …]
|
||||||
synonyms map[string][]string // word → [synonym, …]
|
synonyms map[string][]string // word → [synonym, …]
|
||||||
gloss map[string]string // word → Chinese gloss
|
gloss map[string]string // word → Chinese gloss
|
||||||
|
phonetic map[string]string // word → IPA
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
||||||
@@ -75,6 +77,10 @@ func (l *Lexicon) load() {
|
|||||||
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := gunzipJSON(phoneticGz, &l.phonetic); err != nil {
|
||||||
|
l.loadErr = fmt.Errorf("load phonetic: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +101,9 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.Gloss = lookupGloss(l.gloss, norm)
|
res.Gloss = lookupGloss(l.gloss, norm)
|
||||||
|
// Phonetic is a word→string map like the gloss, so the same de-inflecting
|
||||||
|
// candidate walk resolves "running" → "run", etc.
|
||||||
|
res.Phonetic = lookupGloss(l.phonetic, norm)
|
||||||
|
|
||||||
if raw := lookupDefs(l.defs, norm); raw != nil {
|
if raw := lookupDefs(l.defs, norm); raw != nil {
|
||||||
for _, m := range raw {
|
for _, m := range raw {
|
||||||
|
|||||||
101
scripts/build_phonetic.py
Normal file
101
scripts/build_phonetic.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the embedded English-phonetic dataset.
|
||||||
|
|
||||||
|
Petal's writer is a native Mandarin speaker learning English, so the useful
|
||||||
|
pronunciation aid is *how to say the English word* — shown in the word popover
|
||||||
|
beside the 🔊 read-aloud button. (Pinyin would annotate Chinese she already
|
||||||
|
reads fluently, so it isn't built.) The phonetic spelling comes from the same
|
||||||
|
ECDICT source as the Chinese gloss, which already carries a `phonetic` column.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
|
||||||
|
# Full build from ECDICT (same CSV used by build_gloss.py):
|
||||||
|
curl -sL https://raw.githubusercontent.com/skywind3000/ECDICT/master/ecdict.csv -o ecdict.csv
|
||||||
|
python3 scripts/build_phonetic.py ecdict.csv internal/lexicon/data/phonetic.json.gz
|
||||||
|
|
||||||
|
# Write just the built-in common-word seed (no CSV needed — what ships in-repo
|
||||||
|
# so the feature works out of the box until a full build is run):
|
||||||
|
python3 scripts/build_phonetic.py --seed internal/lexicon/data/phonetic.json.gz
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Same frequency gate as the gloss build, so coverage matches the words an ESL
|
||||||
|
# writer actually meets and the asset stays small.
|
||||||
|
MAX_RANK = 50000
|
||||||
|
|
||||||
|
# Single English word (letters with internal apostrophe/hyphen).
|
||||||
|
WORD_RE = re.compile(r"^[a-z][a-z'\-]*[a-z]$|^[a-z]$")
|
||||||
|
|
||||||
|
# A small, hand-checked seed of common words → IPA. This ships in the repo so the
|
||||||
|
# phonetic line is live immediately; a full ECDICT build overwrites it with broad
|
||||||
|
# coverage. Kept deliberately short and correct rather than large and shaky.
|
||||||
|
SEED = {
|
||||||
|
"hello": "həˈloʊ", "world": "wɜːrld", "people": "ˈpiːpl", "water": "ˈwɔːtər",
|
||||||
|
"river": "ˈrɪvər", "house": "haʊs", "school": "skuːl", "friend": "frɛnd",
|
||||||
|
"family": "ˈfæməli", "mother": "ˈmʌðər", "father": "ˈfɑːðər", "woman": "ˈwʊmən",
|
||||||
|
"language": "ˈlæŋɡwɪdʒ", "english": "ˈɪŋɡlɪʃ", "write": "raɪt", "writing": "ˈraɪtɪŋ",
|
||||||
|
"read": "riːd", "word": "wɜːrd", "sentence": "ˈsɛntəns", "beautiful": "ˈbjuːtɪfl",
|
||||||
|
"happy": "ˈhæpi", "love": "lʌv", "thought": "θɔːt", "through": "θruː",
|
||||||
|
"though": "ðoʊ", "enough": "ɪˈnʌf", "question": "ˈkwɛstʃən", "answer": "ˈænsər",
|
||||||
|
"because": "bɪˈkɔːz", "people's": "ˈpiːplz", "important": "ɪmˈpɔːrtnt",
|
||||||
|
"different": "ˈdɪfərənt", "remember": "rɪˈmɛmbər", "together": "təˈɡɛðər",
|
||||||
|
"morning": "ˈmɔːrnɪŋ", "tonight": "təˈnaɪt", "yesterday": "ˈjɛstərdeɪ",
|
||||||
|
"tomorrow": "təˈmɒroʊ", "weather": "ˈwɛðər", "color": "ˈkʌlər", "colour": "ˈkʌlər",
|
||||||
|
"favourite": "ˈfeɪvərɪt", "favorite": "ˈfeɪvərɪt", "comfortable": "ˈkʌmftəbl",
|
||||||
|
"vegetable": "ˈvɛdʒtəbl", "interesting": "ˈɪntrəstɪŋ", "necessary": "ˈnɛsəsɛri",
|
||||||
|
"february": "ˈfɛbruɛri", "wednesday": "ˈwɛnzdeɪ",
|
||||||
|
"island": "ˈaɪlənd", "knife": "naɪf", "know": "noʊ", "knee": "niː",
|
||||||
|
"hour": "ˈaʊər", "honest": "ˈɒnɪst", "listen": "ˈlɪsn", "castle": "ˈkæsl",
|
||||||
|
"thank": "θæŋk", "think": "θɪŋk", "thing": "θɪŋ", "three": "θriː",
|
||||||
|
"voice": "vɔɪs", "choice": "tʃɔɪs", "nature": "ˈneɪtʃər", "picture": "ˈpɪktʃər",
|
||||||
|
"future": "ˈfjuːtʃər", "culture": "ˈkʌltʃər", "measure": "ˈmɛʒər",
|
||||||
|
"usually": "ˈjuːʒuəli", "decision": "dɪˈsɪʒn", "delicious": "dɪˈlɪʃəs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_phonetic(p: str) -> str:
|
||||||
|
"""Normalize an ECDICT phonetic field: trim, strip wrapping slashes/brackets."""
|
||||||
|
p = p.strip().strip("/[]").strip()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def build_from_csv(src: str) -> dict:
|
||||||
|
out = {}
|
||||||
|
with open(src, newline="", encoding="utf-8") as fh:
|
||||||
|
for row in csv.DictReader(fh):
|
||||||
|
word = (row.get("word") or "").strip().lower()
|
||||||
|
phon = clean_phonetic(row.get("phonetic") or "")
|
||||||
|
if not word or not phon or not WORD_RE.match(word):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rank = int(row.get("frq") or 0) or int(row.get("bnc") or 0)
|
||||||
|
except ValueError:
|
||||||
|
rank = 0
|
||||||
|
if rank <= 0 or rank > MAX_RANK:
|
||||||
|
continue
|
||||||
|
out[word] = phon
|
||||||
|
# Make sure the curated seed is always present (and authoritative).
|
||||||
|
out.update(SEED)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
if args[0] == "--seed":
|
||||||
|
data, dest = dict(SEED), args[1]
|
||||||
|
else:
|
||||||
|
src, dest = args[0], args[1]
|
||||||
|
data = build_from_csv(src)
|
||||||
|
with gzip.open(dest, "wt", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
print(f"wrote {len(data)} phonetic entries to {dest}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -192,6 +192,38 @@ export default function App() {
|
|||||||
setDocText('')
|
setDocText('')
|
||||||
}, [saveNow, isBlankDraft])
|
}, [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(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await api.deleteDoc(id)
|
await api.deleteDoc(id)
|
||||||
@@ -371,6 +403,7 @@ export default function App() {
|
|||||||
onSelect={openDoc}
|
onSelect={openDoc}
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
onToggleTag={handleToggleTag}
|
onToggleTag={handleToggleTag}
|
||||||
onCreateTag={handleCreateTag}
|
onCreateTag={handleCreateTag}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface WordMeaning {
|
|||||||
export interface WordInfo {
|
export interface WordInfo {
|
||||||
word: string
|
word: string
|
||||||
gloss: string // Chinese translation; '' when the word isn't in the gloss set
|
gloss: string // Chinese translation; '' when the word isn't in the gloss set
|
||||||
|
phonetic: string // IPA for the English word; '' when absent
|
||||||
definitions: WordMeaning[]
|
definitions: WordMeaning[]
|
||||||
synonyms: string[]
|
synonyms: string[]
|
||||||
}
|
}
|
||||||
@@ -164,6 +165,10 @@ export const api = {
|
|||||||
exportUrl: (id: string, format: ExportFormat) =>
|
exportUrl: (id: string, format: ExportFormat) =>
|
||||||
`/api/docs/${id}/export?format=${format}`,
|
`/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.
|
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
|
||||||
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
||||||
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
|
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
|
||||||
|
|||||||
32
web/src/audio/speech.ts
Normal file
32
web/src/audio/speech.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 { DocListItem } from './DocListItem'
|
||||||
import { SearchBox } from './SearchBox'
|
import { SearchBox } from './SearchBox'
|
||||||
import { TagChip } from './TagChip'
|
import { TagChip } from './TagChip'
|
||||||
@@ -11,10 +11,19 @@ interface Props {
|
|||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
|
onDuplicate: (id: string) => void
|
||||||
onToggleTag: (docId: string, tag: Tag) => void
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
onCreateTag: (docId: string, name: string, color: TagColor) => 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
|
// 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).
|
// button, and the document browser (each row showing its tag chips).
|
||||||
export function DocList({
|
export function DocList({
|
||||||
@@ -24,6 +33,7 @@ export function DocList({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onCreate,
|
onCreate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
onToggleTag,
|
onToggleTag,
|
||||||
onCreateTag,
|
onCreateTag,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -31,11 +41,21 @@ export function DocList({
|
|||||||
// disappears from the roster.
|
// disappears from the roster.
|
||||||
const [filterId, setFilterId] = useState<string | null>(null)
|
const [filterId, setFilterId] = useState<string | null>(null)
|
||||||
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
||||||
|
const [sort, setSort] = useState<SortMode>('recent')
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(() => {
|
||||||
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
|
const base = activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs
|
||||||
[docs, activeFilter],
|
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.
|
// 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])
|
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
|
<span className="text-base leading-none">+</span> New Doc
|
||||||
</button>
|
</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">
|
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
@@ -86,12 +125,30 @@ export function DocList({
|
|||||||
roster={roster}
|
roster={roster}
|
||||||
onSelect={() => onSelect(doc.id)}
|
onSelect={() => onSelect(doc.id)}
|
||||||
onDelete={() => onDelete(doc.id)}
|
onDelete={() => onDelete(doc.id)}
|
||||||
|
onDuplicate={() => onDuplicate(doc.id)}
|
||||||
onToggleTag={onToggleTag}
|
onToggleTag={onToggleTag}
|
||||||
onCreateTag={onCreateTag}
|
onCreateTag={onCreateTag}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
roster: Tag[]
|
roster: Tag[]
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
onToggleTag: (docId: string, tag: Tag) => void
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export function DocListItem({
|
|||||||
roster,
|
roster,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
onToggleTag,
|
onToggleTag,
|
||||||
onCreateTag,
|
onCreateTag,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -65,6 +67,19 @@ export function DocListItem({
|
|||||||
>
|
>
|
||||||
🏷️
|
🏷️
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Delete document"
|
aria-label="Delete document"
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ import { MisspellCard } from './MisspellCard'
|
|||||||
import { WordCard } from './WordCard'
|
import { WordCard } from './WordCard'
|
||||||
import { GlossTip } from './GlossTip'
|
import { GlossTip } from './GlossTip'
|
||||||
import { SelectionBubble } from './SelectionBubble'
|
import { SelectionBubble } from './SelectionBubble'
|
||||||
|
import { SearchHighlight } from './SearchHighlight'
|
||||||
|
import { FindReplace } from './FindReplace'
|
||||||
|
import { Typography } from './Typography'
|
||||||
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||||
import { api, type Suggestion, type WordInfo } from '../../api/client'
|
import { api, type Suggestion, type WordInfo } from '../../api/client'
|
||||||
|
import { speak, speechSupported } from '../../audio/speech'
|
||||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
export interface EditorChange {
|
export interface EditorChange {
|
||||||
@@ -207,8 +211,14 @@ export function EditorCore({
|
|||||||
// The rewrite affordances: a bubble over the current selection, and the
|
// The rewrite affordances: a bubble over the current selection, and the
|
||||||
// preview that replaces it once a style is chosen.
|
// preview that replaces it once a style is chosen.
|
||||||
const [selection, setSelection] = useState<SelectionState | null>(null)
|
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 [rewrite, setRewrite] = useState<RewriteState | null>(null)
|
||||||
const rewriteReqRef = useRef(0)
|
const rewriteReqRef = useRef(0)
|
||||||
|
// The in-document Find & Replace bar (Ctrl/Cmd+F).
|
||||||
|
const [findOpen, setFindOpen] = useState(false)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -229,6 +239,8 @@ export function EditorCore({
|
|||||||
CharacterCount,
|
CharacterCount,
|
||||||
SuggestionHighlight,
|
SuggestionHighlight,
|
||||||
SpellCheck,
|
SpellCheck,
|
||||||
|
SearchHighlight,
|
||||||
|
Typography,
|
||||||
],
|
],
|
||||||
content: parseDoc(initialContent),
|
content: parseDoc(initialContent),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -467,20 +479,17 @@ export function EditorCore({
|
|||||||
setMisspell(null)
|
setMisspell(null)
|
||||||
}, [misspell, onAddWord])
|
}, [misspell, onAddWord])
|
||||||
|
|
||||||
// Right-click a word to look it up: resolve the exact word span under the
|
// openWordLookup resolves the exact word span at a document position, anchors a
|
||||||
// pointer, anchor a popover beneath it, and kick off the offline lookup. The
|
// popover beneath it, and kicks off the offline lookup. The card opens
|
||||||
// card opens immediately in a loading state and fills in when the (local)
|
// immediately in a loading state and fills in when the (local) lookup returns.
|
||||||
// lookup returns. Right-clicking off any word falls through to the native menu.
|
// Shared by right-click, the keyboard shortcut (caret), and touch long-press.
|
||||||
const handleContextMenu = useCallback(
|
const openWordLookup = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(pos: number) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
const range = wordAt(editor.state.doc, pos)
|
||||||
if (!coords) return
|
|
||||||
const range = wordAt(editor.state.doc, coords.pos)
|
|
||||||
if (!range) return
|
if (!range) return
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper) return
|
if (!wrapper) return
|
||||||
e.preventDefault()
|
|
||||||
// Anchor under the word itself (not the click point) so the card lines up
|
// Anchor under the word itself (not the click point) so the card lines up
|
||||||
// with the text the way the spelling popover does.
|
// with the text the way the spelling popover does.
|
||||||
const start = editor.view.coordsAtPos(range.from)
|
const start = editor.view.coordsAtPos(range.from)
|
||||||
@@ -511,6 +520,37 @@ export function EditorCore({
|
|||||||
[editor, closeCard],
|
[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(
|
const replaceWord = useCallback(
|
||||||
(synonym: string) => {
|
(synonym: string) => {
|
||||||
if (editor && wordInfo) {
|
if (editor && wordInfo) {
|
||||||
@@ -683,10 +723,52 @@ export function EditorCore({
|
|||||||
return () => document.removeEventListener('mousedown', onDown)
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
}, [misspell])
|
}, [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(() => () => {
|
useEffect(() => () => {
|
||||||
clearTimeout(closeTimer.current)
|
clearTimeout(closeTimer.current)
|
||||||
clearTimeout(confettiTimer.current)
|
clearTimeout(confettiTimer.current)
|
||||||
clearTimeout(glossTimer.current)
|
clearTimeout(glossTimer.current)
|
||||||
|
clearTimeout(longPressTimer.current)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
||||||
@@ -724,16 +806,22 @@ export function EditorCore({
|
|||||||
onMouseOut={handleMouseOut}
|
onMouseOut={handleMouseOut}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
onClick={handleSpellClick}
|
onClick={handleSpellClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={cancelLongPress}
|
||||||
|
onTouchEnd={cancelLongPress}
|
||||||
>
|
>
|
||||||
<EditorContent editor={editor} className="h-full" />
|
<EditorContent editor={editor} className="h-full" />
|
||||||
|
{findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
|
||||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||||
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
|
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
|
||||||
{selection && !rewrite && (
|
{selection && !rewrite && !dragging && (
|
||||||
<SelectionBubble
|
<SelectionBubble
|
||||||
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||||
onRewrite={handleRewrite}
|
onRewrite={handleRewrite}
|
||||||
|
onSpeak={speechSupported() ? () => speak(selection.text) : null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{rewrite && (
|
{rewrite && (
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
web/src/components/Editor/SearchHighlight.ts
Normal file
143
web/src/components/Editor/SearchHighlight.ts
Normal 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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -27,11 +27,14 @@ export const REWRITE_STYLES: RewriteStyle[] = [
|
|||||||
interface Props {
|
interface Props {
|
||||||
style: React.CSSProperties
|
style: React.CSSProperties
|
||||||
onRewrite: (style: string) => void
|
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"
|
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
|
const [natural, ...tones] = REWRITE_STYLES
|
||||||
|
|
||||||
return (
|
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"
|
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label="Rewrite the selection"
|
aria-label="Rewrite the selection"
|
||||||
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-pill)',
|
borderRadius: 'var(--radius-pill)',
|
||||||
boxShadow: 'var(--shadow-soft)',
|
boxShadow: 'var(--shadow-soft)',
|
||||||
fontFamily: CJK,
|
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,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
onClick={() => onRewrite(natural.value)}
|
onClick={() => onRewrite(natural.value)}
|
||||||
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
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"
|
title="Rewrite the selection to sound more natural"
|
||||||
>
|
>
|
||||||
<span aria-hidden>{natural.emoji}</span>
|
<span aria-hidden>{natural.emoji}</span>
|
||||||
@@ -63,15 +70,30 @@ export function SelectionBubble({ style, onRewrite }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</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)' }} />
|
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
||||||
|
|
||||||
{tones.map((t) => (
|
{tones.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.value}
|
key={t.value}
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
onClick={() => onRewrite(t.value)}
|
onClick={() => onRewrite(t.value)}
|
||||||
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
|
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)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
||||||
|
|||||||
29
web/src/components/Editor/Typography.ts
Normal file
29
web/src/components/Editor/Typography.ts
Normal 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: '…' }),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { WordInfo } from '../../api/client'
|
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)
|
// 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
|
// 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 definitions = info?.definitions ?? []
|
||||||
const synonyms = info?.synonyms ?? []
|
const synonyms = info?.synonyms ?? []
|
||||||
const gloss = info?.gloss ?? ''
|
const gloss = info?.gloss ?? ''
|
||||||
|
const phonetic = info?.phonetic ?? ''
|
||||||
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,8 +48,28 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
|||||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||||
{word}
|
{word}
|
||||||
</span>
|
</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>
|
</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. */}
|
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
|
||||||
{gloss && (
|
{gloss && (
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function Swatch({
|
|||||||
// the current selection without re-rendering the whole tree on every keystroke.
|
// the current selection without re-rendering the whole tree on every keystroke.
|
||||||
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||||
// Which popover (if any) is open. Only one at a time.
|
// 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 [linkUrl, setLinkUrl] = useState('')
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -222,6 +222,26 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
|||||||
close()
|
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 onPickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (file) uploadImageInto(editor.view, file)
|
if (file) uploadImageInto(editor.view, file)
|
||||||
@@ -507,6 +527,50 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</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 />
|
<Divider />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -223,6 +223,20 @@ button, a, input {
|
|||||||
animation: petal-suggestion-in 200ms ease both;
|
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 ------------------------------------------------------------
|
/* --- Spell check ------------------------------------------------------------
|
||||||
Browser-side nspell flags misspellings with a soft rose wavy underline (a
|
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
|
gentler take on the classic red squiggle, to fit the pastel palette). Like the
|
||||||
|
|||||||
Reference in New Issue
Block a user