Compare commits
23 Commits
feat/petal
...
20442d1356
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20442d1356 | ||
|
|
375e0dde4c | ||
|
|
7824b68ea1 | ||
|
|
631279bd3a | ||
|
|
9576340391 | ||
|
|
aebdc2679a | ||
|
|
8bd2509bc2 | ||
|
|
0f2f753efa | ||
|
|
46db0a3e16 | ||
|
|
1bd38b20e8 | ||
|
|
82e2bcc777 | ||
|
|
035dcf5d25 | ||
|
|
5120b1e5a2 | ||
|
|
adc0cdff1c | ||
|
|
9d6698dcc6 | ||
|
|
6783ce7a51 | ||
|
|
3ac6382696 | ||
|
|
db737fa612 | ||
|
|
6e6e4edce7 | ||
|
|
58c326d0cf | ||
|
|
045ec19b92 | ||
|
|
f6fa73b17b | ||
|
|
8cf78130bf |
17
.env.example
17
.env.example
@@ -7,6 +7,9 @@ BASE_URL=http://localhost:8080
|
||||
# Database (SQLite, pure-Go modernc — no cgo)
|
||||
DATABASE_PATH=./data/petal.db
|
||||
|
||||
# On-disk store for images pasted/dropped/inserted in the editor
|
||||
IMAGE_DIR=./data/images
|
||||
|
||||
# LLM
|
||||
LLM_BACKEND=vllm # vllm | ollama
|
||||
LLM_ENDPOINT=http://localhost:8000 # vLLM :8000, Ollama :11434
|
||||
@@ -14,6 +17,20 @@ LLM_MODEL= # checkpoint model (small/fast). Never hardcod
|
||||
LLM_CHAT_MODEL= # Ask Petal model (Mandarin-native). Falls back to LLM_MODEL if empty.
|
||||
LLM_TIMEOUT=30s
|
||||
|
||||
# Read-aloud (TTS). Off unless TTS_ENDPOINT is set — when empty, the /api/tts route
|
||||
# is not mounted and the frontend falls back to the browser's Web Speech API.
|
||||
# Backed by a local Piper HTTP server (python3 -m piper.http_server).
|
||||
# Each Piper HTTP server loads ONE voice, so English and Chinese need separate
|
||||
# instances (different ports). zh is only routed when both TTS_ENDPOINT_ZH and
|
||||
# TTS_VOICE_ZH are set; otherwise Chinese falls back to Web Speech.
|
||||
TTS_ENDPOINT= # e.g. http://127.0.0.1:5005 — empty disables server TTS
|
||||
TTS_ENDPOINT_ZH= # e.g. http://127.0.0.1:5006 — Chinese Piper instance
|
||||
TTS_VOICE_EN=en_US-amy-medium # Piper voice id for English
|
||||
TTS_VOICE_ZH=zh_CN-huayan-medium # Piper voice id for Chinese
|
||||
TTS_CACHE_DIR=./data/tts # on-disk store for synthesized clips (content-addressed)
|
||||
TTS_TIMEOUT=15s
|
||||
TTS_AUDIO_FORMAT=mp3 # mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
|
||||
|
||||
# --- Deferred (not wired in the local-dev build) ---
|
||||
|
||||
# Auth (Authentik OIDC) — deferred; single hardcoded local user for now
|
||||
|
||||
@@ -95,6 +95,45 @@ 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.
|
||||
- **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}`.
|
||||
|
||||
### Phase 12 — Collocation coach 🔜 (planned 2026-06-26)
|
||||
**Why:** ESL writers nail grammar but miss *which words go together* — "do a decision" → "make a decision", "strong rain" → "heavy rain". These aren't *wrong*, so the grammar pass won't flag them; they're just non-native. Gentle "natives usually say…" hints are the single highest-leverage upgrade for making her writing sound native. **Build this first** — it's small and de-risks the migration-rebuild pattern Phase 13 also needs.
|
||||
|
||||
**Key insight:** the suggestion pipeline is already generic over a `pass` + a `pendingScope` "family" (`runPass` in `internal/suggestions/handlers.go`; grammar + voice already prove it). Collocation drops in as a **third family** and reuses the entire accept/dismiss/re-anchoring/rail/Mandarin-explanation machinery — no new frontend rendering layer.
|
||||
- [ ] `internal/llm/collocation.go` — `CollocationInterval` (~25s) + `RunCollocation(...)`, reusing the existing `ParseCheckpoint` parser (same as `RunVoice`). No new parsing.
|
||||
- [ ] `internal/llm/prompts.go` — `CollocationMessages(contentText, tone)`. **This prompt is the whole feature**: flag only non-wrong-but-non-native word pairings; explicitly defer real grammar errors to the grammar family; phrase every explanation as warm "natives usually say…" with a Mandarin gloss — never "error/wrong".
|
||||
- [ ] `internal/db/models.go` + migration `0005` — new suggestion type `collocation`. ⚠️ The `type` column has a `CHECK(...)` and **SQLite can't `ALTER` a CHECK** → migration must **rebuild** the `suggestions` table (create new w/ extended CHECK, copy rows, drop, rename, recreate `idx_suggestions_doc_id`). Not a one-line ALTER.
|
||||
- [ ] `internal/suggestions/handlers.go` — add `collocationScope` (`deleteWhere: "type = 'collocation'"`, `forceType: collocation`), a `CollocationLimit` on `Handler` (+ wire in `New`), register `POST /{id}/collocation`. Mirrors `voice`, ~15 lines. Also extend `normalizeType` to accept the new type.
|
||||
- [ ] Frontend: `suggestionMeta.ts` collocation entry (💡 icon + its own warm color so cards read as friendly tips); `client.ts` `collocation(docId)`; a gentle trigger like the existing "Check my voice" pill — **"Make it sound natural 🌸"** — rendering straight into `SuggestionRail`/`SuggestionCard`.
|
||||
- [ ] Verify via the millenia UI-test harness (real-browser editor check) + go/tsc/vite clean. **Est. ~½ day** (mostly the new prompt + the table-rebuild migration).
|
||||
|
||||
### Phase 13 — Vocabulary garden (spaced repetition) 🔜 (planned 2026-06-26)
|
||||
**Why:** the lexicon (`internal/lexicon`) is a stateless static-dataset lookup — **nothing records which words she's looked up.** Capturing them turns passive lookups into real vocabulary, and the review surface ties straight into the "petal garden" delight idea (words become blossoms; the sleeping kitten naps among them). Build **after** Phase 12.
|
||||
- [ ] New `internal/vocab` package + migration `0005`/`0006` — `vocab_words` table: `word, gloss, phonetic, example` (sentence captured at lookup for context), `doc_id`, + SM-2-lite scheduling: `due_at, interval_days, ease, reps, lapses, last_reviewed`; `UNIQUE(user_id, word)`.
|
||||
- [ ] Auto-capture: when `WordCard` calls `lookupWord`, also fire `POST /api/vocab` (upsert; new word → `due_at = now + 1 day`). Looking words up *is* the data source — zero extra effort from her.
|
||||
- [ ] Endpoints: `POST /api/vocab` (record/upsert), `GET /api/vocab/due` (cards due now), `POST /api/vocab/{id}/review` (grade again/good/easy → reschedule), `GET /api/vocab` (full garden), `DELETE /api/vocab/{id}`.
|
||||
- [ ] SR scheduler: gentle SM-2-lite / Leitner intervals (1d → 3d → 7d → 16d → 35d). No streak-shaming. This is the only genuinely new logic.
|
||||
- [ ] Frontend Garden panel (sibling to `HistoryPanel`/`DocList`): each learned word a blossom, more reps → more bloomed; the `PetalCompanion` kitten naps among them. Flashcard review: captured sentence with the word blanked → recall → EN↔中文 flip → again/good/easy (direction flips for recognition *and* production). A 🤍 "save to garden" on `WordCard` for explicit saves alongside auto-capture.
|
||||
- [ ] Verify via the millenia UI-test harness + go/tsc/vite clean. **Est. ~1½–2 days** (new package + table + panel/review UI; reuses existing panel/companion patterns).
|
||||
|
||||
### Phase 14 — Companion warmth + bedtime nag + night mode ✅
|
||||
**Why:** the companion kitten is the heart of Petal's "built for her" feel. Three additions: (1) a wider, fresher pool of **encouraging phrases** so cheers don't repeat as quickly; (2) when she's still writing **late at night (≥11pm)**, the kitten gently nags her to go to bed; (3) at the same hour the whole app drifts into a calm **night mode** — dark moonlit theme + the falling petals become **falling stars**. Caring, never scolding — the sleepy-cat gag makes "you should be sleeping too 🐱💤" land perfectly. Self-contained, frontend-only.
|
||||
- [x] `web/src/components/Companion/tips.ts` — `ENCOURAGEMENTS` grown from 5→10 bilingual zh-first lines so cheers rotate fresher. New `BEDTIME: Line[]` array — four warm/playful lines (user-supplied English wit: "I bet your bed is missing you right now", "A tired writer is a bad writer", "Sleep is a wondrous enabler", "Hear that? No… everyone is sleeping and you should be too") with gentle Mandarin leads.
|
||||
- [x] `web/src/components/Companion/useCompanion.ts` — bedtime check folded into the existing 10s heartbeat (after the idle-return + break check, before the generic tip): `isBedtime()` = local hour ≥ 23 or < 4 (`new Date().getHours()`, the writer's machine clock). Only fires while actively writing (idle branch returns first). Own `lastBedtime` ref + `BEDTIME_GAP` 30min cooldown; respects `PROACTIVE_GAP`. New `BubbleTone` `'bedtime'` paces `readBubbleMs` (BUBBLE_MS + 4s lingers a touch longer for a wind-down read). Window knobs `BEDTIME_FROM`/`BEDTIME_TO` so the 11pm–4am range is one edit to retune.
|
||||
- [x] No tone-based bubble styling exists, so no CSS needed; the tone is metadata for pacing only. Sound stays the rotating pop.
|
||||
- [x] **Night mode** — `web/src/lib/night.ts` centralizes `isBedtime()` + the `BEDTIME_FROM`/`BEDTIME_TO` window (shared with the companion nag so they always agree). `web/src/hooks/useNightMode.ts` re-checks every 60s and toggles a `petal-night` class on `<html>`. `index.css` adds an `html.petal-night` block that **only re-points the palette tokens** (`--color-bg/surface/border/plum/muted/accent…`) to a dark moonlit set — every Tailwind color utility reads them via `var()`, so the whole UI flips with zero component changes (verified: `.text-plum{color:var(--color-plum)}`). Accent/type colors kept (they pop on dark); 600ms bg/color fade for a gentle dusk transition; print stays white (the `#fff` override is inside `@media print`).
|
||||
- [x] **Falling stars** — `PetalFall` gains a `night` prop. Particles are **chunky cartoon power stars** (`makeCartoonStar`, Mario/Kirby-style: fat 5-point shape, glossy radial fill, puffy round-join colored outline, corner shine + soft glow halo so they pop off the dark sky) in 5 candy colors (`CARTOON_COLORS`), mixed ~70/30 with small four-point twinkle sparkles (`makeStarSprite`/`STAR_PALETTE`) for depth. Every star clearly **spins** (random direction, ~0.5–2.2 rad/s so the quick ones really whirl while slow ones drift for contrast; guaranteed min speed since a 5-point star is symmetric every 72°), **falls straight down** (no sway — that's a petal thing), and shimmers via a shallow alpha pulse (not blink). Effect re-inits on the day↔night flip. App wires `const night = useNightMode()` → `<PetalFall night={night} />`. All sprites are canvas-drawn (offline, no asset files) — `sprites[]` is an image array, so a real PNG/SVG star could drop in later without restructuring.
|
||||
- [x] Verified: tsc clean, vite build OK, companion vitest 45/45. **Real-browser screenshots** (local Playwright + Chromium, clock mocked to 23:30): day = warm cream + pink sakura petals; night = dark plum-indigo + twinkling stars + glowing sleepy kitten. Both pretty (acceptance criterion).
|
||||
|
||||
### Deferred (post-v1-local)
|
||||
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
||||
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||
@@ -103,8 +142,13 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
### Next-up (post-v1 product, agreed with user 2026-06-26)
|
||||
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
|
||||
- [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)
|
||||
- [ ] **Phase 12 — collocation coach**: gentle "natives usually say…" hints for non-native word pairings, as a third suggestion family. 🔜 (see Phase 12 above; build first)
|
||||
- [ ] **Phase 13 — vocabulary garden**: spaced-repetition review built from looked-up words, surfaced as a blooming garden. 🔜 (see Phase 13 above)
|
||||
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||
|
||||
## Session log
|
||||
- 2026-06-26: **Phase 14 complete** (companion warmth + bedtime nag + night mode). `tips.ts`: `ENCOURAGEMENTS` 5→10 lines; new `BEDTIME` array (4 lines, user-supplied English wit + gentle Mandarin leads). `useCompanion.ts`: bedtime branch in the 10s heartbeat (after idle-return + break, before the generic tip); only nudges while actively writing; own `lastBedtime` ref + 30min `BEDTIME_GAP`, respects `PROACTIVE_GAP`; new `'bedtime'` `BubbleTone` lingers ~4s longer. **Night mode** (added same session, user request): `lib/night.ts` centralizes `isBedtime()` + window (now shared by the nag too); `hooks/useNightMode.ts` toggles `petal-night` on `<html>` (60s re-check); `index.css` `html.petal-night` re-points only the palette tokens → whole UI flips via `var()` (no component edits), 600ms dusk fade, print stays white; `PetalFall` gains a `night` prop → chunky cartoon power stars (`makeCartoonStar`, Mario/Kirby-style, 5 candy colors) mixed ~70/30 with small twinkle sparkles, gentle spin + shallow shimmer, effect re-inits on flip; App: `useNightMode()` → `<PetalFall night={night}/>`. tsc + vite clean, companion vitest 45/45; verified with real-browser Playwright screenshots (clock mocked to 23:30) — day petals/cream vs night stars/dark-plum, both pretty. Bedtime window is `BEDTIME_FROM`/`BEDTIME_TO` (local clock) for easy retune.
|
||||
- 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 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.**
|
||||
|
||||
@@ -15,9 +15,11 @@ import (
|
||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/docs"
|
||||
"gitea.parodia.dev/drwily/petal/internal/images"
|
||||
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
||||
"gitea.parodia.dev/drwily/petal/internal/tts"
|
||||
"gitea.parodia.dev/drwily/petal/web"
|
||||
)
|
||||
|
||||
@@ -80,6 +82,21 @@ func main() {
|
||||
lex := lexicon.NewHandler()
|
||||
api.Mount("/word", lex.Routes())
|
||||
api.Mount("/gloss", lex.GlossRoutes())
|
||||
|
||||
// Editor image uploads, stored on disk and served back by content hash.
|
||||
imgHandler, err := images.New(cfg.ImageDir)
|
||||
if err != nil {
|
||||
log.Fatalf("image store: %v", err)
|
||||
}
|
||||
api.Mount("/images", imgHandler.Routes())
|
||||
|
||||
// Read-aloud: proxy short passages to a local Piper TTS server. Only
|
||||
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
|
||||
// back to the browser's Web Speech API on its own.
|
||||
if ttsHandler, ok := tts.New(cfg); ok {
|
||||
api.Mount("/tts", ttsHandler.Routes())
|
||||
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
|
||||
}
|
||||
})
|
||||
|
||||
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||
|
||||
65
deploy/README.md
Normal file
65
deploy/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Deploying read-aloud (Piper TTS) to millenia
|
||||
|
||||
Petal's read-aloud generates audio with a local **Piper** neural-TTS server and
|
||||
transcodes it to mp3 with **ffmpeg**. Both run on millenia (192.168.1.212);
|
||||
nothing leaves the box. If Piper is down or `TTS_ENDPOINT` is unset, the frontend
|
||||
falls back to the browser's Web Speech API automatically.
|
||||
|
||||
## 1. Piper as a systemd service (one-time)
|
||||
|
||||
```bash
|
||||
# from this repo, on your workstation:
|
||||
scp deploy/piper.service deploy/setup-piper.sh 192.168.1.212:/tmp/
|
||||
ssh 192.168.1.212 'cd /tmp && sudo ./setup-piper.sh'
|
||||
```
|
||||
|
||||
`setup-piper.sh` creates `~/piper/venv`, installs `piper-tts[http]`, downloads the
|
||||
`en_US-amy-medium` voice into `~/piper/voices`, installs+enables `piper.service`
|
||||
(loopback :5005), and smoke-tests it. Idempotent.
|
||||
|
||||
Check it any time: `systemctl status piper`, `journalctl -u piper -f`.
|
||||
|
||||
## 2. Point petal at Piper + redeploy the binary
|
||||
|
||||
Add to petal's environment (its `.env` or launch env):
|
||||
|
||||
```
|
||||
TTS_ENDPOINT=http://127.0.0.1:5005
|
||||
TTS_VOICE_EN=en_US-amy-medium
|
||||
TTS_AUDIO_FORMAT=mp3
|
||||
```
|
||||
|
||||
Then ship the rebuilt binary (`go build -o petal ./cmd/server` already done) and
|
||||
restart the petal `:8088` session. Confirm the log line:
|
||||
`read-aloud enabled (TTS endpoint=http://127.0.0.1:5005)`.
|
||||
|
||||
## 3. Verify
|
||||
|
||||
```bash
|
||||
# on millenia — end-to-end through petal, including ffmpeg transcode:
|
||||
curl -sf -X POST localhost:8088/api/tts \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"text":"hello there","lang":"en-US"}' -o /tmp/petal-tts.mp3 \
|
||||
&& file /tmp/petal-tts.mp3 # expect: Audio file ... MPEG ... layer III
|
||||
```
|
||||
|
||||
- Second identical call is served from the cache (`~/petal/.../data/tts/*.mp3`).
|
||||
- A language with no configured instance returns 404 → client uses Web Speech.
|
||||
- Browser check via the uitest harness (`~/petal/uitest`): tap a word in the
|
||||
WordCard / select a sentence and hit speak — expect the natural Piper voice.
|
||||
|
||||
## Chinese voice (live)
|
||||
|
||||
Each Piper HTTP server loads ONE model, so Chinese runs as a **second instance**:
|
||||
`piper-zh.service` on :5006 with `zh_CN-huayan-medium`. Deployed via:
|
||||
|
||||
```bash
|
||||
scp deploy/piper-zh.service 192.168.1.212:~/.config/systemd/user/
|
||||
ssh 192.168.1.212 'export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
~/piper/venv/bin/python -m piper.download_voices zh_CN-huayan-medium --data-dir ~/piper/voices
|
||||
systemctl --user daemon-reload && systemctl --user enable --now piper-zh.service'
|
||||
```
|
||||
|
||||
Then in petal's `start.sh`: `TTS_ENDPOINT_ZH=http://127.0.0.1:5006` and
|
||||
`TTS_VOICE_ZH=zh_CN-huayan-medium`. The handler maps language → instance from config,
|
||||
so adding more languages is just another instance + env pair (no code change).
|
||||
17
deploy/piper-zh.service
Normal file
17
deploy/piper-zh.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Piper TTS HTTP server — Chinese voice (read-aloud backend for petal)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Bound to loopback only — petal proxies to it; never exposed off-box.
|
||||
ExecStart=%h/piper/venv/bin/python -m piper.http_server \
|
||||
-m zh_CN-huayan-medium \
|
||||
--data-dir %h/piper/voices \
|
||||
--host 127.0.0.1 --port 5006
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
17
deploy/piper.service
Normal file
17
deploy/piper.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Piper TTS HTTP server (read-aloud backend for petal)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Bound to loopback only — petal proxies to it; never exposed off-box.
|
||||
ExecStart=%h/piper/venv/bin/python -m piper.http_server \
|
||||
-m en_US-amy-medium \
|
||||
--data-dir %h/piper/voices \
|
||||
--host 127.0.0.1 --port 5005
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
43
deploy/setup-piper.sh
Executable file
43
deploy/setup-piper.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time Piper TTS setup on millenia as a *user* systemd service (no sudo).
|
||||
# Idempotent — safe to re-run. Run on the box:
|
||||
# bash setup-piper.sh
|
||||
# To survive logout/reboot, also (once, needs root):
|
||||
# sudo loginctl enable-linger "$USER"
|
||||
set -euo pipefail
|
||||
|
||||
PIPER_HOME="$HOME/piper"
|
||||
VENV="$PIPER_HOME/venv"
|
||||
VOICES="$PIPER_HOME/voices"
|
||||
VOICE=en_US-amy-medium
|
||||
UNIT_DIR="$HOME/.config/systemd/user"
|
||||
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
|
||||
|
||||
echo ">> ffmpeg check (petal transcodes Piper WAV -> mp3 with it):"
|
||||
command -v ffmpeg >/dev/null || { echo "ffmpeg not found on PATH"; exit 1; }
|
||||
|
||||
echo ">> venv + piper-tts[http]"
|
||||
[ -d "$VENV" ] || python3 -m venv "$VENV"
|
||||
"$VENV/bin/pip" install --upgrade pip >/dev/null
|
||||
"$VENV/bin/pip" install "piper-tts[http]"
|
||||
|
||||
echo ">> download voice $VOICE"
|
||||
mkdir -p "$VOICES"
|
||||
"$VENV/bin/python" -m piper.download_voices "$VOICE" --data-dir "$VOICES"
|
||||
|
||||
echo ">> install user service"
|
||||
mkdir -p "$UNIT_DIR"
|
||||
install -m 0644 "$(dirname "$0")/piper.service" "$UNIT_DIR/piper.service"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now piper.service
|
||||
|
||||
echo ">> wait + status"
|
||||
sleep 2
|
||||
systemctl --user --no-pager --full status piper.service | head -n 6 || true
|
||||
|
||||
echo ">> smoke test"
|
||||
curl -sf -X POST localhost:5005/ \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"text\":\"hello there\",\"voice\":\"$VOICE\"}" -o /tmp/piper-test.wav \
|
||||
&& echo "OK: /tmp/piper-test.wav ($(stat -c%s /tmp/piper-test.wav) bytes)" \
|
||||
|| { echo "smoke test FAILED"; journalctl --user -u piper.service --no-pager | tail -n 20; exit 1; }
|
||||
@@ -11,6 +11,7 @@ type Config struct {
|
||||
Port string
|
||||
BaseURL string
|
||||
DatabasePath string
|
||||
ImageDir string // on-disk store for editor image uploads
|
||||
|
||||
// LLM
|
||||
LLMBackend string // "vllm" | "ollama"
|
||||
@@ -19,6 +20,17 @@ type Config struct {
|
||||
LLMChatModel string // Ask Petal model; falls back to LLMModel if empty
|
||||
LLMTimeout time.Duration
|
||||
|
||||
// TTS (read-aloud). Off unless TTSEndpoint is set — when empty, the /api/tts
|
||||
// route isn't mounted and the frontend falls back to the browser's Web Speech
|
||||
// API. Endpoint points at a local Piper HTTP server.
|
||||
TTSEndpoint string // Piper instance serving the English voice
|
||||
TTSEndpointZH string // Piper instance serving the Chinese voice; empty = zh falls back to Web Speech
|
||||
TTSVoiceEN string // Piper voice id for English (e.g. en_US-amy-medium)
|
||||
TTSVoiceZH string // Piper voice id for Chinese (e.g. zh_CN-huayan-medium)
|
||||
TTSCacheDir string // on-disk store for synthesized clips (content-addressed)
|
||||
TTSTimeout time.Duration
|
||||
TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
|
||||
|
||||
// Auth (deferred — not wired in the local-dev build, kept for later)
|
||||
AuthentikURL string
|
||||
AuthentikClientID string
|
||||
@@ -32,6 +44,7 @@ func Load() *Config {
|
||||
Port: env("PORT", "8080"),
|
||||
BaseURL: env("BASE_URL", "http://localhost:8080"),
|
||||
DatabasePath: env("DATABASE_PATH", "./data/petal.db"),
|
||||
ImageDir: env("IMAGE_DIR", "./data/images"),
|
||||
|
||||
LLMBackend: env("LLM_BACKEND", "vllm"),
|
||||
LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"),
|
||||
@@ -39,6 +52,14 @@ func Load() *Config {
|
||||
LLMChatModel: env("LLM_CHAT_MODEL", ""),
|
||||
LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second),
|
||||
|
||||
TTSEndpoint: env("TTS_ENDPOINT", ""),
|
||||
TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""),
|
||||
TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"),
|
||||
TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"),
|
||||
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
|
||||
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
|
||||
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
|
||||
|
||||
AuthentikURL: env("AUTHENTIK_URL", ""),
|
||||
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
|
||||
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
|
||||
|
||||
@@ -9,15 +9,101 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"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) {
|
||||
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
|
||||
@@ -92,7 +178,8 @@ type pmNode struct {
|
||||
}
|
||||
|
||||
type pmMark struct {
|
||||
Type string `json:"type"`
|
||||
Type string `json:"type"`
|
||||
Attrs map[string]any `json:"attrs"`
|
||||
}
|
||||
|
||||
// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
|
||||
@@ -131,6 +218,29 @@ func (n pmNode) hasMark(t string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// attrStr returns a string-valued attribute (e.g. an image src), or "".
|
||||
func (n pmNode) attrStr(key string) string {
|
||||
if n.Attrs == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := n.Attrs[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// markAttr returns a string attribute from the named mark (e.g. a link href).
|
||||
func (n pmNode) markAttr(markType, key string) string {
|
||||
for _, m := range n.Marks {
|
||||
if m.Type == markType && m.Attrs != nil {
|
||||
if s, ok := m.Attrs[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (n pmNode) level() int {
|
||||
if n.Attrs == nil {
|
||||
return 1
|
||||
@@ -173,6 +283,11 @@ func mdBlock(n pmNode, depth int) string {
|
||||
return "```\n" + textContent(n) + "\n```"
|
||||
case "horizontalRule":
|
||||
return "---"
|
||||
case "image":
|
||||
alt := n.attrStr("alt")
|
||||
return fmt.Sprintf("", alt, n.attrStr("src"))
|
||||
case "table":
|
||||
return mdTable(n)
|
||||
case "bulletList", "orderedList":
|
||||
var items []string
|
||||
for i, item := range n.Content {
|
||||
@@ -213,6 +328,55 @@ func mdInline(nodes []pmNode) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mdTable renders a table node as a GitHub-flavored Markdown table. The first
|
||||
// row is used as the header (Markdown tables require one); a separator row is
|
||||
// inserted after it. Cell text is flattened to inline Markdown.
|
||||
func mdTable(n pmNode) string {
|
||||
var rows [][]string
|
||||
for _, row := range n.Content {
|
||||
if row.Type != "tableRow" {
|
||||
continue
|
||||
}
|
||||
var cells []string
|
||||
for _, cell := range row.Content {
|
||||
// A cell holds block children (usually one paragraph); flatten them.
|
||||
var parts []string
|
||||
for _, c := range cell.Content {
|
||||
parts = append(parts, mdInline(c.Content))
|
||||
}
|
||||
// Escape pipes so cell content doesn't break the column layout.
|
||||
cells = append(cells, strings.ReplaceAll(strings.TrimSpace(strings.Join(parts, " ")), "|", "\\|"))
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
cols := 0
|
||||
for _, r := range rows {
|
||||
if len(r) > cols {
|
||||
cols = len(r)
|
||||
}
|
||||
}
|
||||
pad := func(r []string) string {
|
||||
for len(r) < cols {
|
||||
r = append(r, "")
|
||||
}
|
||||
return "| " + strings.Join(r, " | ") + " |"
|
||||
}
|
||||
var lines []string
|
||||
lines = append(lines, pad(rows[0]))
|
||||
sep := make([]string, cols)
|
||||
for i := range sep {
|
||||
sep[i] = "---"
|
||||
}
|
||||
lines = append(lines, "| "+strings.Join(sep, " | ")+" |")
|
||||
for _, r := range rows[1:] {
|
||||
lines = append(lines, pad(r))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func applyMdMarks(n pmNode) string {
|
||||
t := n.Text
|
||||
if n.hasMark("code") {
|
||||
@@ -230,6 +394,9 @@ func applyMdMarks(n pmNode) string {
|
||||
if n.hasMark("underline") {
|
||||
t = "<u>" + t + "</u>"
|
||||
}
|
||||
if href := n.markAttr("link", "href"); href != "" {
|
||||
t = "[" + t + "](" + href + ")"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -347,6 +514,11 @@ func htmlBlock(n pmNode) string {
|
||||
return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
|
||||
case "horizontalRule":
|
||||
return "<hr>\n"
|
||||
case "image":
|
||||
alt := htmlEscape(n.attrStr("alt"))
|
||||
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\n", htmlEscape(n.attrStr("src")), alt)
|
||||
case "table":
|
||||
return htmlTable(n)
|
||||
case "bulletList", "orderedList":
|
||||
tag := "ul"
|
||||
if n.Type == "orderedList" {
|
||||
@@ -376,6 +548,38 @@ func htmlBlock(n pmNode) string {
|
||||
}
|
||||
}
|
||||
|
||||
// htmlTable renders a table node as an HTML <table>. tableHeader cells become
|
||||
// <th>, tableCell cells become <td>; each cell's block children are flattened
|
||||
// to inline HTML.
|
||||
func htmlTable(n pmNode) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("<table>\n")
|
||||
for _, row := range n.Content {
|
||||
if row.Type != "tableRow" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<tr>")
|
||||
for _, cell := range row.Content {
|
||||
tag := "td"
|
||||
if cell.Type == "tableHeader" {
|
||||
tag = "th"
|
||||
}
|
||||
var inner strings.Builder
|
||||
for _, c := range cell.Content {
|
||||
if c.Type == "paragraph" {
|
||||
inner.WriteString(htmlInline(c.Content))
|
||||
} else {
|
||||
inner.WriteString(htmlBlock(c))
|
||||
}
|
||||
}
|
||||
b.WriteString("<" + tag + ">" + inner.String() + "</" + tag + ">")
|
||||
}
|
||||
b.WriteString("</tr>\n")
|
||||
}
|
||||
b.WriteString("</table>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func htmlInline(nodes []pmNode) string {
|
||||
var b strings.Builder
|
||||
for _, n := range nodes {
|
||||
@@ -408,6 +612,12 @@ func applyHTMLMarks(n pmNode) string {
|
||||
if n.hasMark("strike") {
|
||||
t = "<s>" + t + "</s>"
|
||||
}
|
||||
if n.hasMark("highlight") {
|
||||
t = "<mark>" + t + "</mark>"
|
||||
}
|
||||
if href := n.markAttr("link", "href"); href != "" {
|
||||
t = fmt.Sprintf("<a href=\"%s\">%s</a>", htmlEscape(href), t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -500,6 +710,16 @@ func docxBlock(n pmNode) string {
|
||||
return b.String()
|
||||
case "horizontalRule":
|
||||
return `<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>`
|
||||
case "image":
|
||||
// Image bytes aren't embedded (that needs media parts); leave a labeled
|
||||
// placeholder so the reader knows an image belonged here.
|
||||
label := n.attrStr("alt")
|
||||
if label == "" {
|
||||
label = "image"
|
||||
}
|
||||
return docxPara(pmNode{Content: []pmNode{{Type: "text", Text: "[" + label + "]", Marks: []pmMark{{Type: "italic"}}}}}, "")
|
||||
case "table":
|
||||
return docxTable(n)
|
||||
case "bulletList", "orderedList":
|
||||
var b strings.Builder
|
||||
for i, item := range n.Content {
|
||||
@@ -520,6 +740,48 @@ func docxBlock(n pmNode) string {
|
||||
}
|
||||
}
|
||||
|
||||
// docxTable renders a table node as a Word table (w:tbl) with single-line
|
||||
// borders. Header cells get a shaded background; every cell's block children are
|
||||
// rendered as paragraphs inside the cell.
|
||||
func docxTable(n pmNode) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/>` +
|
||||
`<w:tblBorders>` +
|
||||
`<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||
`<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||
`<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||
`<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||
`<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||
`<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
||||
`</w:tblBorders></w:tblPr>`)
|
||||
for _, row := range n.Content {
|
||||
if row.Type != "tableRow" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<w:tr>")
|
||||
for _, cell := range row.Content {
|
||||
b.WriteString("<w:tc><w:tcPr>")
|
||||
if cell.Type == "tableHeader" {
|
||||
b.WriteString(`<w:shd w:val="clear" w:color="auto" w:fill="FFF0F5"/>`)
|
||||
}
|
||||
b.WriteString("</w:tcPr>")
|
||||
// A cell must contain at least one paragraph to be valid.
|
||||
if len(cell.Content) == 0 {
|
||||
b.WriteString("<w:p/>")
|
||||
}
|
||||
for _, c := range cell.Content {
|
||||
b.WriteString(docxBlock(c))
|
||||
}
|
||||
b.WriteString("</w:tc>")
|
||||
}
|
||||
b.WriteString("</w:tr>")
|
||||
}
|
||||
b.WriteString("</w:tbl>")
|
||||
// Word needs a paragraph after a table; otherwise consecutive tables merge.
|
||||
b.WriteString("<w:p/>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// docxPara renders a block's inline children as a Word paragraph. An optional
|
||||
// style name (e.g. "Quote") and an optional literal text prefix (for list
|
||||
// markers) may be supplied.
|
||||
@@ -571,6 +833,15 @@ func docxRun(n pmNode) string {
|
||||
if n.hasMark("strike") {
|
||||
props.WriteString("<w:strike/>")
|
||||
}
|
||||
if n.hasMark("highlight") {
|
||||
// Word's text highlight only supports a fixed palette of named colors.
|
||||
props.WriteString(`<w:highlight w:val="yellow"/>`)
|
||||
}
|
||||
if n.markAttr("link", "href") != "" {
|
||||
// Without hyperlink relationships, style links as blue underlined text so
|
||||
// they at least read as links (the URL itself is preserved in md/html).
|
||||
props.WriteString(`<w:color w:val="2563EB"/><w:u w:val="single"/>`)
|
||||
}
|
||||
rpr := ""
|
||||
if props.Len() > 0 {
|
||||
rpr = "<w:rPr>" + props.String() + "</w:rPr>"
|
||||
|
||||
@@ -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) {
|
||||
srv := newTestServer(t)
|
||||
id := seedRichDoc(t, srv)
|
||||
@@ -126,6 +165,77 @@ func TestExportDocx(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// richDocJSON2 exercises the newer node/mark types: links, highlight, an image,
|
||||
// and a table (with a header row). Each export format should carry them through.
|
||||
const richDocJSON2 = `{"type":"doc","content":[` +
|
||||
`{"type":"paragraph","content":[` +
|
||||
`{"type":"text","marks":[{"type":"link","attrs":{"href":"https://petal.test"}}],"text":"site"},` +
|
||||
`{"type":"text","text":" and "},` +
|
||||
`{"type":"text","marks":[{"type":"highlight","attrs":{"color":"#FFF1A8"}}],"text":"lit"}` +
|
||||
`]},` +
|
||||
`{"type":"image","attrs":{"src":"/api/images/abc123.png","alt":"a cat"}},` +
|
||||
`{"type":"table","content":[` +
|
||||
`{"type":"tableRow","content":[` +
|
||||
`{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"Name"}]}]},` +
|
||||
`{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"年龄"}]}]}` +
|
||||
`]},` +
|
||||
`{"type":"tableRow","content":[` +
|
||||
`{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"Mei"}]}]},` +
|
||||
`{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"30"}]}]}` +
|
||||
`]}` +
|
||||
`]}` +
|
||||
`]}`
|
||||
|
||||
func seedRichDoc2(t *testing.T, srv http.Handler) string {
|
||||
t.Helper()
|
||||
id := newDoc(t, srv)
|
||||
body := `{"title":"Rich2","content":` + jsonString(richDocJSON2) + `,"content_text":"site and lit\nName 年龄 Mei 30","word_count":6}`
|
||||
if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK {
|
||||
t.Fatalf("seed rich doc 2: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestExportLinksImagesTables(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := seedRichDoc2(t, srv)
|
||||
|
||||
md := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "").Body.String()
|
||||
for _, want := range []string{"[site](https://petal.test)", "", "| Name | 年龄 |", "| --- | --- |", "| Mei | 30 |"} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Fatalf("markdown missing %q in:\n%s", want, md)
|
||||
}
|
||||
}
|
||||
|
||||
html := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "").Body.String()
|
||||
for _, want := range []string{`<a href="https://petal.test">site</a>`, "<mark>lit</mark>", `<img src="/api/images/abc123.png" alt="a cat">`, "<th>Name</th>", "<td>Mei</td>"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("html missing %q in:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
|
||||
docx := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "")
|
||||
raw := docx.Body.Bytes()
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("docx not a zip: %v", err)
|
||||
}
|
||||
var docXML string
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, _ := f.Open()
|
||||
b, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
docXML = string(b)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"<w:tbl>", "Name", "年龄", "[a cat]", "<w:highlight"} {
|
||||
if !strings.Contains(docXML, want) {
|
||||
t.Fatalf("docx document.xml missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportUnsupportedFormat(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := newDoc(t, srv)
|
||||
|
||||
132
internal/images/handler.go
Normal file
132
internal/images/handler.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Package images implements a tiny content-addressed image store: writers upload
|
||||
// images from the editor, they're saved to disk under the configured directory,
|
||||
// and served back by hashed filename. Content addressing means the same image
|
||||
// pasted twice is stored once, and URLs are stable and cacheable forever.
|
||||
package images
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// maxUploadBytes caps a single image at 10 MiB — generous for a writing tool,
|
||||
// small enough to keep a careless paste from filling the disk.
|
||||
const maxUploadBytes = 10 << 20
|
||||
|
||||
// extByContentType maps the image types we accept to a canonical extension. The
|
||||
// allowlist doubles as validation: anything not here is rejected.
|
||||
var extByContentType = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
// Handler serves the upload + fetch endpoints, backed by a directory on disk.
|
||||
type Handler struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// New constructs a Handler, ensuring the storage directory exists.
|
||||
func New(dir string) (*Handler, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{dir: dir}, nil
|
||||
}
|
||||
|
||||
// Routes mounts the image endpoints. Mount under "/images" so the full paths are
|
||||
// POST /api/images (upload) and GET /api/images/{name} (fetch).
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/", h.upload)
|
||||
r.Get("/{name}", h.serve)
|
||||
return r
|
||||
}
|
||||
|
||||
// upload accepts a single multipart "image" field, sniffs and validates its
|
||||
// type, and writes it under a content hash so identical images dedupe. Responds
|
||||
// with the served URL the editor inserts.
|
||||
func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||
file, _, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
http.Error(w, "expected an 'image' file field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Error(w, "could not read upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Trust a sniff over the client-declared type. SVG isn't reliably sniffable
|
||||
// (DetectContentType returns text/plain or text/xml), so fall back to a
|
||||
// lightweight tag check for it.
|
||||
ct := http.DetectContentType(data)
|
||||
ext, ok := extByContentType[ct]
|
||||
if !ok {
|
||||
if looksLikeSVG(data) {
|
||||
ext, ok = ".svg", true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "unsupported image type", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
name := hex.EncodeToString(sum[:])[:32] + ext
|
||||
path := filepath.Join(h.dir, name)
|
||||
|
||||
// Skip the write if this exact content is already stored.
|
||||
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
http.Error(w, "could not store image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/api/images/" + name})
|
||||
}
|
||||
|
||||
// serve returns a stored image by its hashed filename. The filename is validated
|
||||
// to be a bare name (no path separators) so it can't escape the storage dir, and
|
||||
// served with a long-lived cache header since content-addressed URLs never change.
|
||||
func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(h.dir, filepath.Base(name))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
// looksLikeSVG does a cheap check for an <svg root tag near the start of the
|
||||
// file, since DetectContentType doesn't recognize SVG.
|
||||
func looksLikeSVG(data []byte) bool {
|
||||
head := data
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(head)), "<svg")
|
||||
}
|
||||
97
internal/images/handler_test.go
Normal file
97
internal/images/handler_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// a 1x1 transparent PNG.
|
||||
var pngBytes = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
func uploadReq(t *testing.T, field string, data []byte) *http.Request {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile(field, "x.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write(data)
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func TestUploadAndServe(t *testing.T) {
|
||||
h, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := h.Routes()
|
||||
|
||||
// Upload a PNG → expect a JSON url under /api/images/.
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, uploadReq(t, "image", pngBytes))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var resp struct{ URL string }
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") {
|
||||
t.Fatalf("unexpected url %q", resp.URL)
|
||||
}
|
||||
|
||||
// The same content uploaded again dedupes to the same URL.
|
||||
rec2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec2, uploadReq(t, "image", pngBytes))
|
||||
var resp2 struct{ URL string }
|
||||
json.Unmarshal(rec2.Body.Bytes(), &resp2)
|
||||
if resp2.URL != resp.URL {
|
||||
t.Fatalf("expected dedup to same url, got %q vs %q", resp2.URL, resp.URL)
|
||||
}
|
||||
|
||||
// Fetch it back.
|
||||
name := strings.TrimPrefix(resp.URL, "/api/images/")
|
||||
rec3 := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/"+name, nil))
|
||||
if rec3.Code != http.StatusOK {
|
||||
t.Fatalf("serve code=%d", rec3.Code)
|
||||
}
|
||||
if !bytes.Equal(rec3.Body.Bytes(), pngBytes) {
|
||||
t.Fatal("served bytes differ from uploaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsNonImage(t *testing.T) {
|
||||
h, _ := New(t.TempDir())
|
||||
r := h.Routes()
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("expected 415, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeMissing(t *testing.T) {
|
||||
h, _ := New(t.TempDir())
|
||||
r := h.Routes()
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/deadbeef.png", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,12 @@ var synonymsGz []byte
|
||||
//
|
||||
//go:embed data/gloss.json.gz
|
||||
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 {
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
Phonetic string `json:"phonetic"` // IPA for the English word; "" when absent
|
||||
Definitions []Meaning `json:"definitions"`
|
||||
Synonyms []string `json:"synonyms"`
|
||||
}
|
||||
@@ -56,6 +57,7 @@ type Lexicon struct {
|
||||
defs map[string][][]string // word → [[pos, def, example], …]
|
||||
synonyms map[string][]string // word → [synonym, …]
|
||||
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.
|
||||
@@ -75,6 +77,10 @@ func (l *Lexicon) load() {
|
||||
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
||||
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)
|
||||
// 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 {
|
||||
for _, m := range raw {
|
||||
|
||||
@@ -14,6 +14,16 @@ import (
|
||||
// that protects the inference endpoint from rapid repeat checks.
|
||||
const CheckpointInterval = 30 * time.Second
|
||||
|
||||
// checkpointMaxTokens caps the checkpoint's generated output. Each suggestion
|
||||
// echoes its original sentence, a replacement, and a friendly explanation, so
|
||||
// the full JSON for the prompt's "up to 5 issues" runs ~2,000 tokens on a long
|
||||
// document (measured ~2,007 for a 380-word doc). The old 1,024 cap truncated
|
||||
// the JSON mid-array — done_reason "length" — leaving an object that never
|
||||
// closed, so ParseCheckpoint found no parseable JSON and the pass 502'd on
|
||||
// every long doc. This is a ceiling, not a target: the model stops at its JSON
|
||||
// close well before it, so the headroom costs nothing on shorter docs.
|
||||
const checkpointMaxTokens = 4096
|
||||
|
||||
// RawSuggestion is one item as the model emits it. Positions are resolved
|
||||
// server-side from Original; the frontend re-anchors by string at render time.
|
||||
type RawSuggestion struct {
|
||||
@@ -34,7 +44,7 @@ type checkpointResponse struct {
|
||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
||||
MaxTokens: 1024,
|
||||
MaxTokens: checkpointMaxTokens,
|
||||
Temperature: 0.3,
|
||||
RepetitionPenalty: 1.15,
|
||||
TopP: 0.9,
|
||||
@@ -48,20 +58,32 @@ func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone stri
|
||||
|
||||
// ParseCheckpoint extracts the suggestions array from a model response. Smaller
|
||||
// models sometimes wrap JSON in prose or markdown fences despite instructions,
|
||||
// so we salvage the outermost {...} object before decoding.
|
||||
// so we salvage the outermost {...} object before decoding. When the model
|
||||
// truncates mid-array (it ran out of output budget) the outer object never
|
||||
// closes and won't parse — we then recover the suggestion objects that *did*
|
||||
// complete, so a long document still gets feedback instead of a hard failure.
|
||||
func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
|
||||
jsonText := extractJSONObject(raw)
|
||||
if jsonText == "" {
|
||||
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
|
||||
var suggestions []RawSuggestion
|
||||
parsed := false
|
||||
if jsonText := extractJSONObject(raw); jsonText != "" {
|
||||
var resp checkpointResponse
|
||||
if err := json.Unmarshal([]byte(jsonText), &resp); err == nil {
|
||||
suggestions = resp.Suggestions
|
||||
parsed = true
|
||||
}
|
||||
}
|
||||
var parsed checkpointResponse
|
||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("checkpoint: parse JSON: %w (got %q)", err, truncateForError(jsonText))
|
||||
if !parsed {
|
||||
// No closeable object, or it failed to decode (most often a truncated
|
||||
// array). Salvage the individual suggestion objects that completed.
|
||||
suggestions = salvageSuggestions(raw)
|
||||
if suggestions == nil {
|
||||
return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw))
|
||||
}
|
||||
}
|
||||
// Drop items the model returned with an empty original — they can't be
|
||||
// anchored — and normalize whitespace the model may have echoed.
|
||||
out := parsed.Suggestions[:0]
|
||||
for _, s := range parsed.Suggestions {
|
||||
out := suggestions[:0]
|
||||
for _, s := range suggestions {
|
||||
s.Original = strings.TrimSpace(s.Original)
|
||||
s.Replacement = strings.TrimSpace(s.Replacement)
|
||||
if s.Original == "" {
|
||||
@@ -72,12 +94,55 @@ func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// salvageSuggestions recovers as many complete suggestion objects as it can from
|
||||
// a response whose top-level JSON didn't parse — typically one truncated mid
|
||||
// "suggestions" array. It scans the array region for brace-balanced {...}
|
||||
// objects and keeps each that decodes into a RawSuggestion with a non-empty
|
||||
// original; the final, cut-off object simply never balances and is dropped.
|
||||
// Returns nil when there is no salvageable array (so the caller can error).
|
||||
func salvageSuggestions(raw string) []RawSuggestion {
|
||||
key := strings.Index(raw, `"suggestions"`)
|
||||
if key < 0 {
|
||||
return nil
|
||||
}
|
||||
lb := strings.IndexByte(raw[key:], '[')
|
||||
if lb < 0 {
|
||||
return nil
|
||||
}
|
||||
rest := raw[key+lb+1:]
|
||||
out := []RawSuggestion{}
|
||||
for {
|
||||
obj, end := firstBalancedObject(rest)
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
var s RawSuggestion
|
||||
if err := json.Unmarshal([]byte(obj), &s); err == nil && strings.TrimSpace(s.Original) != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
rest = rest[end:]
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractJSONObject returns the substring from the first '{' to its matching
|
||||
// closing '}', or "" if none. Tolerates fences/preamble around the object.
|
||||
func extractJSONObject(s string) string {
|
||||
obj, _ := firstBalancedObject(s)
|
||||
return obj
|
||||
}
|
||||
|
||||
// firstBalancedObject returns the first brace-balanced {...} substring in s and
|
||||
// the index in s just past its closing brace, or ("", -1) if no object closes
|
||||
// (none present, or the only one is truncated). String contents — including
|
||||
// braces inside quoted values — are skipped so they can't unbalance the count.
|
||||
func firstBalancedObject(s string) (string, int) {
|
||||
start := strings.IndexByte(s, '{')
|
||||
if start < 0 {
|
||||
return ""
|
||||
return "", -1
|
||||
}
|
||||
depth := 0
|
||||
inString := false
|
||||
@@ -98,11 +163,11 @@ func extractJSONObject(s string) string {
|
||||
case c == '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return s[start : i+1]
|
||||
return s[start : i+1], i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return "", -1
|
||||
}
|
||||
|
||||
func truncateForError(s string) string {
|
||||
@@ -127,17 +192,33 @@ func NewRateLimiter(interval time.Duration) *RateLimiter {
|
||||
}
|
||||
|
||||
// Allow reports whether a checkpoint may run for docID now. When allowed it
|
||||
// records the time and returns (true, 0); when throttled it returns
|
||||
// (false, retryAfter) where retryAfter is the wait until the next allowed run.
|
||||
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) {
|
||||
// records the time and returns (true, 0, recordedAt); when throttled it returns
|
||||
// (false, retryAfter, zero) where retryAfter is the wait until the next allowed
|
||||
// run. recordedAt lets a caller whose pass fails hand the exact timestamp to
|
||||
// Release so it rolls back only its own slot.
|
||||
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration, time.Time) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
now := time.Now()
|
||||
if last, ok := rl.last[docID]; ok {
|
||||
if elapsed := now.Sub(last); elapsed < rl.interval {
|
||||
return false, rl.interval - elapsed
|
||||
return false, rl.interval - elapsed, time.Time{}
|
||||
}
|
||||
}
|
||||
rl.last[docID] = now
|
||||
return true, 0
|
||||
return true, 0, now
|
||||
}
|
||||
|
||||
// Release rolls back the slot a prior Allow recorded for docID, but only if no
|
||||
// newer Allow has since claimed it. Called when an LLM pass fails: Allow runs
|
||||
// before the model call, so without this a failed checkpoint would hold the
|
||||
// per-document slot for the full interval and a retry (or the frontend's
|
||||
// auto-retry) would hit the throttle path and get the stale/empty set back
|
||||
// instead of re-running. `at` is the time the failed Allow returned.
|
||||
func (rl *RateLimiter) Release(docID string, at time.Time) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
if last, ok := rl.last[docID]; ok && last.Equal(at) {
|
||||
delete(rl.last, docID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,21 @@ func TestParseCheckpoint(t *testing.T) {
|
||||
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
// The bug that 502'd every long doc: the model hit num_predict and the
|
||||
// JSON never closed. Two suggestions completed; the third is cut off.
|
||||
// Salvage keeps the two that completed instead of failing outright.
|
||||
name: "truncated mid-array salvages completed objects",
|
||||
raw: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},{"original":"a apple","replacement":"an apple","explanation":"use an before a vowel","type":"grammar"},{"original":"she relised","replacement":"she real`,
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
// A truncated array where even the first object is incomplete has
|
||||
// nothing to salvage → still an error (the caller releases + retries).
|
||||
name: "truncated before any object closes",
|
||||
raw: `{"suggestions":[{"original":"It was a thursdy morning when Clara`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -65,16 +80,31 @@ func TestParseCheckpoint(t *testing.T) {
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
rl := NewRateLimiter(30 * time.Second)
|
||||
|
||||
if ok, _ := rl.Allow("doc1"); !ok {
|
||||
ok, _, at := rl.Allow("doc1")
|
||||
if !ok {
|
||||
t.Fatal("first call should be allowed")
|
||||
}
|
||||
if ok, retry := rl.Allow("doc1"); ok || retry <= 0 {
|
||||
if ok, retry, _ := rl.Allow("doc1"); ok || retry <= 0 {
|
||||
t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry)
|
||||
}
|
||||
// A different document is independent.
|
||||
if ok, _ := rl.Allow("doc2"); !ok {
|
||||
if ok, _, _ := rl.Allow("doc2"); !ok {
|
||||
t.Fatal("different doc should be allowed")
|
||||
}
|
||||
|
||||
// Releasing doc1's slot (as a failed pass does) lets the next check re-run
|
||||
// immediately instead of waiting out the interval.
|
||||
rl.Release("doc1", at)
|
||||
if ok, _, _ := rl.Allow("doc1"); !ok {
|
||||
t.Fatal("after Release the next call should be allowed again")
|
||||
}
|
||||
// Release only rolls back its own slot: a stale timestamp must not evict the
|
||||
// slot a newer Allow holds (so a late failure can't cancel a fresh in-flight
|
||||
// check). doc2 still holds its slot from above; a zero-time Release is a no-op.
|
||||
rl.Release("doc2", time.Time{})
|
||||
if ok, _, _ := rl.Allow("doc2"); ok {
|
||||
t.Fatal("stale Release must not free doc2's current slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateDoc(t *testing.T) {
|
||||
|
||||
@@ -165,3 +165,24 @@ func RewriteMessages(text, style string) []Message {
|
||||
{Role: "user", Content: text},
|
||||
}
|
||||
}
|
||||
|
||||
// translateSystemPrompt drives the explanation translator: it renders a
|
||||
// suggestion's English explanation into Simplified Chinese so an ESL reader sees
|
||||
// the "why" in her first language. Strict about returning ONLY the translation
|
||||
// (no quotes, no pinyin, no English echo) so it can drop straight into the chat
|
||||
// bubble. Kept warm and plain — these are short, friendly one-liners.
|
||||
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` +
|
||||
`sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` +
|
||||
`suggestion, written for a native Chinese speaker learning English.
|
||||
|
||||
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` +
|
||||
`just the translated sentence.`
|
||||
|
||||
// TranslateMessages builds the message array for translating one short English
|
||||
// explanation into Simplified Chinese.
|
||||
func TranslateMessages(text string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: translateSystemPrompt},
|
||||
{Role: "user", Content: text},
|
||||
}
|
||||
}
|
||||
|
||||
23
internal/llm/translate.go
Normal file
23
internal/llm/translate.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// RunTranslate renders a short English explanation into Simplified Chinese. It
|
||||
// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low
|
||||
// temperature so the translation is faithful rather than creative. Output is
|
||||
// trimmed of any stray surrounding quotes the model may add.
|
||||
func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) {
|
||||
out, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: TranslateMessages(text),
|
||||
MaxTokens: 512,
|
||||
Temperature: 0.2,
|
||||
TopP: 0.9,
|
||||
RepetitionPenalty: 1.1,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanRewrite(out), nil
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.Post("/{id}/accept", h.accept)
|
||||
r.Post("/{id}/dismiss", h.dismiss)
|
||||
r.Post("/{id}/chat", h.chat)
|
||||
r.Post("/{id}/translate", h.translate)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -101,7 +102,8 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := limiter.Allow(docID); !ok {
|
||||
ok, _, slotAt := limiter.Allow(docID)
|
||||
if !ok {
|
||||
// Throttled: return the existing pending set unchanged rather than an
|
||||
// error, so the frontend keeps showing current suggestions.
|
||||
existing, err := h.fetchPending(docID)
|
||||
@@ -115,6 +117,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
|
||||
raw, err := run(r.Context(), h.Client, contentText, tone)
|
||||
if err != nil {
|
||||
// Allow ran before the model call, so a failed pass would otherwise hold
|
||||
// the per-document slot for the full interval — stranding the frontend's
|
||||
// auto-retry on the throttle path. Release it so a retry can re-run.
|
||||
limiter.Release(docID, slotAt)
|
||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -154,6 +160,14 @@ var (
|
||||
// replacePending swaps a document's pending suggestions within one family for a
|
||||
// fresh batch in a single transaction. Accepted/rejected suggestions and the
|
||||
// other family's pending rows are left untouched.
|
||||
//
|
||||
// Suggestions the user already accepted or dismissed are suppressed from the
|
||||
// fresh batch: the model has no memory between passes, so without this it would
|
||||
// re-propose the identical edit on the very next checkpoint — re-nagging a
|
||||
// sentence the user already resolved. This matters most when an accept silently
|
||||
// no-ops (the `original` text couldn't be anchored in the editor, so the doc
|
||||
// never changed): the sentence is unaltered, yet the user shouldn't see the same
|
||||
// card again.
|
||||
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
@@ -168,7 +182,15 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
|
||||
return err
|
||||
}
|
||||
|
||||
actioned, err := actionedKeys(tx, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range raw {
|
||||
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen {
|
||||
continue
|
||||
}
|
||||
typ := scope.forceType
|
||||
if typ == "" {
|
||||
typ = normalizeType(s.Type)
|
||||
@@ -186,6 +208,39 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// actionedKeys returns the set of original→replacement keys the user has already
|
||||
// accepted or rejected for this document, so a fresh checkpoint can skip
|
||||
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the
|
||||
// normalization ParseCheckpoint applies, so a genuinely different edit on the same
|
||||
// sentence is not suppressed.
|
||||
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) {
|
||||
rows, err := tx.Query(
|
||||
`SELECT original, replacement FROM suggestions
|
||||
WHERE doc_id = ? AND status IN (?, ?)`,
|
||||
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var original, replacement string
|
||||
if err := rows.Scan(&original, &replacement); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys[suggestionKey(original, replacement)] = struct{}{}
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// suggestionKey is the dedup identity for a suggestion: its trimmed original and
|
||||
// replacement text. Two suggestions with the same key are "the same edit."
|
||||
func suggestionKey(original, replacement string) string {
|
||||
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement)
|
||||
}
|
||||
|
||||
// listForDoc returns the document's current pending suggestions (used when the
|
||||
// editor loads a document, before any new checkpoint fires).
|
||||
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -127,6 +127,44 @@ func TestCheckpointFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvedSuggestionsNotReproposed proves a checkpoint won't re-nag a
|
||||
// sentence the user already accepted or dismissed: the model returns the same
|
||||
// raw batch on the next pass (it has no memory), but the resolved edits are
|
||||
// suppressed. This is the "accept it, then it nags again moments later" bug.
|
||||
func TestResolvedSuggestionsNotReproposed(t *testing.T) {
|
||||
client := &stubClient{response: `{"suggestions":[
|
||||
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
|
||||
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
|
||||
]}`}
|
||||
srv, docID, h := newTestServer(t, client)
|
||||
// Zero the checkpoint floor so the second pass runs instead of being throttled.
|
||||
h.Limit = llm.NewRateLimiter(0)
|
||||
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
var got []db.Suggestion
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("first pass: want 2, got %d", len(got))
|
||||
}
|
||||
|
||||
// Accept one, dismiss the other.
|
||||
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", "")
|
||||
do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", "")
|
||||
|
||||
// Second checkpoint returns the identical raw batch — both must be suppressed.
|
||||
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var again []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("resolved suggestions should not be re-proposed, got %d: %+v", len(again), again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
||||
// disjoint pending families: running one never wipes the other's flags, and
|
||||
// each endpoint returns the unified pending set.
|
||||
|
||||
57
internal/suggestions/translate.go
Normal file
57
internal/suggestions/translate.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
type translateResponse struct {
|
||||
Translation string `json:"translation"`
|
||||
}
|
||||
|
||||
// translate renders a suggestion's English explanation into Simplified Chinese
|
||||
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first
|
||||
// language instead of a second copy of the same English text. The explanation is
|
||||
// loaded server-side from the suggestion id (scoped to the local user) and never
|
||||
// trusted from the client, mirroring chat (spec Note #10).
|
||||
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
||||
sugID := chi.URLParam(r, "id")
|
||||
|
||||
var explanation string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT s.explanation
|
||||
FROM suggestions s
|
||||
JOIN documents d ON d.id = s.doc_id
|
||||
WHERE s.id = ? AND d.user_id = ?`,
|
||||
sugID, db.LocalUserID,
|
||||
).Scan(&explanation)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
explanation = strings.TrimSpace(explanation)
|
||||
if explanation == "" {
|
||||
writeJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
||||
return
|
||||
}
|
||||
|
||||
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, translateResponse{Translation: out})
|
||||
}
|
||||
264
internal/tts/handler.go
Normal file
264
internal/tts/handler.go
Normal file
@@ -0,0 +1,264 @@
|
||||
// Package tts implements read-aloud: it proxies short passages to a local Piper
|
||||
// HTTP server (a fast, offline neural TTS), optionally transcodes Piper's WAV to
|
||||
// mp3/opus with ffmpeg, and serves the audio back to the editor. Synthesized
|
||||
// clips are content-addressed on disk so tapping the same word twice is instant
|
||||
// and never re-synthesizes. The feature is entirely optional: when TTS_ENDPOINT
|
||||
// is unset the route isn't mounted and the frontend falls back to the browser's
|
||||
// Web Speech API.
|
||||
package tts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||
)
|
||||
|
||||
// maxTextBytes bounds a single synthesis request. Read-aloud is for a word or a
|
||||
// sentence or two, not whole documents; this keeps one tap from pinning Piper.
|
||||
const maxTextBytes = 4000
|
||||
|
||||
// lengthScale slows Piper slightly below natural pace — a learner following along
|
||||
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
|
||||
const lengthScale = 1.1
|
||||
|
||||
// audioFormat describes one output encoding: the cache-file extension, the
|
||||
// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin)
|
||||
// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is".
|
||||
type audioFormat struct {
|
||||
ext string
|
||||
contentType string
|
||||
ffmpegArgs []string
|
||||
}
|
||||
|
||||
// formats maps the configured TTS_AUDIO_FORMAT to its encoding. mp3 is the safe
|
||||
// default (every browser plays it); opus is ~half the size for speech but has
|
||||
// patchier Safari support; wav skips ffmpeg entirely.
|
||||
var formats = map[string]audioFormat{
|
||||
"wav": {ext: ".wav", contentType: "audio/wav"},
|
||||
"mp3": {ext: ".mp3", contentType: "audio/mpeg",
|
||||
ffmpegArgs: []string{"-f", "mp3", "-c:a", "libmp3lame", "-b:a", "64k", "-ac", "1"}},
|
||||
"opus": {ext: ".opus", contentType: "audio/ogg",
|
||||
ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}},
|
||||
}
|
||||
|
||||
// route is the Piper instance and voice id serving one language. Each Piper
|
||||
// HTTP server loads exactly one model, so distinct languages mean distinct
|
||||
// endpoints (e.g. English on :5005, Chinese on :5006).
|
||||
type route struct {
|
||||
endpoint string // Piper HTTP base URL (no trailing slash)
|
||||
voice string // Piper voice id (sent in the request; also part of the cache key)
|
||||
}
|
||||
|
||||
// Handler proxies synthesis to Piper and caches the result on disk.
|
||||
type Handler struct {
|
||||
routes map[string]route // base language (e.g. "en", "zh") -> Piper instance
|
||||
cacheDir string
|
||||
format audioFormat
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New builds a Handler from config. It returns (nil, false) when TTS_ENDPOINT is
|
||||
// unset, signalling main to skip mounting the route. An unknown TTS_AUDIO_FORMAT
|
||||
// falls back to mp3 rather than failing the whole server.
|
||||
func New(cfg *config.Config) (*Handler, bool) {
|
||||
if strings.TrimSpace(cfg.TTSEndpoint) == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
format, ok := formats[strings.ToLower(strings.TrimSpace(cfg.TTSFormat))]
|
||||
if !ok {
|
||||
format = formats["mp3"]
|
||||
}
|
||||
|
||||
// Map by base language so en-US, en-GB, etc. all resolve to the English
|
||||
// instance (the client sends BCP-47 tags like the old Web Speech path did).
|
||||
// A language is only routable when both its endpoint and voice are set;
|
||||
// otherwise the client falls back to Web Speech for that language.
|
||||
routes := map[string]route{}
|
||||
if cfg.TTSVoiceEN != "" {
|
||||
routes["en"] = route{strings.TrimRight(cfg.TTSEndpoint, "/"), cfg.TTSVoiceEN}
|
||||
}
|
||||
if cfg.TTSEndpointZH != "" && cfg.TTSVoiceZH != "" {
|
||||
routes["zh"] = route{strings.TrimRight(cfg.TTSEndpointZH, "/"), cfg.TTSVoiceZH}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
|
||||
// A missing cache dir isn't fatal — synthesis still works, it just won't
|
||||
// cache. Disable the feature only on endpoint absence, not this.
|
||||
fmt.Fprintf(os.Stderr, "tts: cache dir %q: %v\n", cfg.TTSCacheDir, err)
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
routes: routes,
|
||||
cacheDir: cfg.TTSCacheDir,
|
||||
format: format,
|
||||
client: &http.Client{Timeout: cfg.TTSTimeout},
|
||||
}, true
|
||||
}
|
||||
|
||||
// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
|
||||
// POST /api/tts.
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/", h.synth)
|
||||
return r
|
||||
}
|
||||
|
||||
// synthRequest is the body the editor posts: a passage and the BCP-47 language
|
||||
// tag it's written in (e.g. "en-US", "zh-CN").
|
||||
type synthRequest struct {
|
||||
Text string `json:"text"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
// synth resolves a voice for the requested language, returns cached audio when
|
||||
// present, and otherwise asks Piper to synthesize, transcodes if configured,
|
||||
// caches, and serves. An unconfigured language yields 404 so the client can fall
|
||||
// back to Web Speech without treating it as an error.
|
||||
func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
|
||||
var req synthRequest
|
||||
// Cap the raw body generously above maxTextBytes to leave room for JSON
|
||||
// framing and the lang field; the text itself is truncated after decoding.
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, maxTextBytes+1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(req.Text)
|
||||
if text == "" {
|
||||
http.Error(w, "empty text", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(text) > maxTextBytes {
|
||||
// Trim back to the last valid rune boundary so we never hand Piper a
|
||||
// half-encoded multibyte character (common with Chinese, 3 bytes/char).
|
||||
text = text[:maxTextBytes]
|
||||
for len(text) > 0 && !utf8.ValidString(text) {
|
||||
text = text[:len(text)-1]
|
||||
}
|
||||
}
|
||||
|
||||
rt, ok := h.routes[baseLang(req.Lang)]
|
||||
if !ok {
|
||||
// No voice for this language — let the client fall back to Web Speech.
|
||||
http.Error(w, "no voice for language", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Content-addressed: identical (voice, text) → identical clip. The format
|
||||
// extension keeps encodings from colliding in the same dir.
|
||||
sum := sha256.Sum256([]byte(rt.voice + "\n" + text))
|
||||
name := hex.EncodeToString(sum[:])[:32] + h.format.ext
|
||||
path := filepath.Join(h.cacheDir, name)
|
||||
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
h.serve(w, r, path)
|
||||
return
|
||||
}
|
||||
|
||||
audio, err := h.synthesize(r.Context(), rt, text)
|
||||
if err != nil {
|
||||
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
||||
fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort cache write via a temp file + rename so a concurrent reader
|
||||
// never sees a half-written clip. A failed write just means no caching.
|
||||
if h.cacheDir != "" {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, audio, 0o644); err == nil {
|
||||
_ = os.Rename(tmp, path)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", h.format.contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
_, _ = w.Write(audio)
|
||||
}
|
||||
|
||||
// serve streams a cached clip with a long-lived immutable cache header (the URL
|
||||
// is content-addressed, so the bytes never change for a given request).
|
||||
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {
|
||||
w.Header().Set("Content-Type", h.format.contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
// synthesize POSTs to the route's Piper instance, then transcodes the returned
|
||||
// WAV when the configured format calls for it.
|
||||
func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte, error) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"text": text,
|
||||
"voice": rt.voice,
|
||||
"length_scale": lengthScale,
|
||||
})
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+"/", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := h.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return nil, fmt.Errorf("piper %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
wav, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if h.format.ffmpegArgs == nil {
|
||||
return wav, nil
|
||||
}
|
||||
return transcode(ctx, wav, h.format.ffmpegArgs)
|
||||
}
|
||||
|
||||
// transcode pipes WAV bytes through ffmpeg (stdin → stdout) into the target
|
||||
// encoding. ffmpeg is assumed on PATH; an error here surfaces as a 502.
|
||||
func transcode(ctx context.Context, wav []byte, args []string) ([]byte, error) {
|
||||
// Guard ffmpeg against a hang independent of the HTTP client timeout.
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
full := append([]string{"-hide_banner", "-loglevel", "error", "-i", "pipe:0"}, args...)
|
||||
full = append(full, "pipe:1")
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", full...)
|
||||
cmd.Stdin = bytes.NewReader(wav)
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg: %v: %s", err, strings.TrimSpace(errBuf.String()))
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// baseLang reduces a BCP-47 tag to its primary subtag, lowercased: "en-US" → "en",
|
||||
// "zh-CN" → "zh", "" → "". Mirrors the client's pickVoice base-language matching.
|
||||
func baseLang(tag string) string {
|
||||
tag = strings.ToLower(strings.TrimSpace(tag))
|
||||
if i := strings.IndexAny(tag, "-_"); i >= 0 {
|
||||
return tag[:i]
|
||||
}
|
||||
return tag
|
||||
}
|
||||
170
internal/tts/handler_test.go
Normal file
170
internal/tts/handler_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newStubPiper returns a fake Piper server that echoes a fixed WAV body and
|
||||
// records how many times it was called and the last request payload.
|
||||
func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEcho) {
|
||||
t.Helper()
|
||||
var calls int32
|
||||
last := &synthEcho{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
last.voice, _ = req["voice"].(string)
|
||||
last.text, _ = req["text"].(string)
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &calls, last
|
||||
}
|
||||
|
||||
type synthEcho struct{ voice, text string }
|
||||
|
||||
// newHandler builds a wav-format handler (no ffmpeg) pointed at a stub server.
|
||||
func newHandler(t *testing.T, endpoint string) *Handler {
|
||||
t.Helper()
|
||||
return &Handler{
|
||||
routes: map[string]route{"en": {strings.TrimRight(endpoint, "/"), "en_US-amy-medium"}},
|
||||
cacheDir: t.TempDir(),
|
||||
format: formats["wav"],
|
||||
client: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b))
|
||||
rr := httptest.NewRecorder()
|
||||
h.synth(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestSynthSuccessAndVoiceSelection(t *testing.T) {
|
||||
wav := []byte("RIFF....fake-wav")
|
||||
srv, calls, last := newStubPiper(t, wav)
|
||||
h := newHandler(t, srv.URL)
|
||||
|
||||
rr := post(t, h, "hello there", "en-US")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if got := rr.Body.Bytes(); !bytes.Equal(got, wav) {
|
||||
t.Fatalf("body = %q, want the piper wav", got)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "audio/wav" {
|
||||
t.Fatalf("content-type = %q, want audio/wav", ct)
|
||||
}
|
||||
if last.voice != "en_US-amy-medium" {
|
||||
t.Fatalf("piper voice = %q, want en_US-amy-medium", last.voice)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("piper calls = %d, want 1", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesByLanguageToSeparateInstances(t *testing.T) {
|
||||
enSrv, enCalls, _ := newStubPiper(t, []byte("EN-wav"))
|
||||
zhSrv, zhCalls, zhLast := newStubPiper(t, []byte("ZH-wav"))
|
||||
h := &Handler{
|
||||
routes: map[string]route{
|
||||
"en": {strings.TrimRight(enSrv.URL, "/"), "en_US-amy-medium"},
|
||||
"zh": {strings.TrimRight(zhSrv.URL, "/"), "zh_CN-huayan-medium"},
|
||||
},
|
||||
cacheDir: t.TempDir(),
|
||||
format: formats["wav"],
|
||||
client: http.DefaultClient,
|
||||
}
|
||||
|
||||
if rr := post(t, h, "你好世界", "zh-CN"); rr.Code != http.StatusOK {
|
||||
t.Fatalf("zh status = %d, want 200", rr.Code)
|
||||
}
|
||||
if *zhCalls != 1 || *enCalls != 0 {
|
||||
t.Fatalf("calls en=%d zh=%d, want en=0 zh=1 (zh routed to zh instance)", *enCalls, *zhCalls)
|
||||
}
|
||||
if zhLast.voice != "zh_CN-huayan-medium" {
|
||||
t.Fatalf("zh voice = %q, want zh_CN-huayan-medium", zhLast.voice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownLanguageReturns404(t *testing.T) {
|
||||
srv, calls, _ := newStubPiper(t, []byte("x"))
|
||||
h := newHandler(t, srv.URL)
|
||||
|
||||
rr := post(t, h, "你好", "zh-CN") // only "en" is configured
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Fatalf("piper calls = %d, want 0 (should not synthesize)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheHitSkipsPiper(t *testing.T) {
|
||||
srv, calls, _ := newStubPiper(t, []byte("RIFF....fake-wav"))
|
||||
h := newHandler(t, srv.URL)
|
||||
|
||||
if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK {
|
||||
t.Fatalf("first status = %d, want 200", rr.Code)
|
||||
}
|
||||
if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK {
|
||||
t.Fatalf("second status = %d, want 200", rr.Code)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("piper calls = %d, want 1 (second served from cache)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTextReturns400(t *testing.T) {
|
||||
srv, _, _ := newStubPiper(t, []byte("x"))
|
||||
h := newHandler(t, srv.URL)
|
||||
|
||||
if rr := post(t, h, " ", "en-US"); rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextIsCapped(t *testing.T) {
|
||||
srv, _, last := newStubPiper(t, []byte("x"))
|
||||
h := newHandler(t, srv.URL)
|
||||
|
||||
long := strings.Repeat("a", maxTextBytes+500)
|
||||
if rr := post(t, h, long, "en-US"); rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if len(last.text) != maxTextBytes {
|
||||
t.Fatalf("piper received %d chars, want capped to %d", len(last.text), maxTextBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseLang(t *testing.T) {
|
||||
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "en": "en", "": ""}
|
||||
for in, want := range cases {
|
||||
if got := baseLang(in); got != want {
|
||||
t.Errorf("baseLang(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensure the stub's body is fully consumable (guards against the LimitReader cap
|
||||
// accidentally truncating a normal request body in synth()).
|
||||
func TestRequestBodyNotTruncated(t *testing.T) {
|
||||
srv, _, last := newStubPiper(t, []byte("x"))
|
||||
h := newHandler(t, srv.URL)
|
||||
text := strings.Repeat("word ", 200) // ~1000 bytes, under the cap
|
||||
post(t, h, text, "en-US")
|
||||
if last.text != strings.TrimSpace(text) {
|
||||
t.Fatalf("piper text length = %d, want %d", len(last.text), len(strings.TrimSpace(text)))
|
||||
}
|
||||
}
|
||||
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()
|
||||
124
web/package-lock.json
generated
124
web/package-lock.json
generated
@@ -9,7 +9,15 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
"@tiptap/extension-color": "^2.27.2",
|
||||
"@tiptap/extension-highlight": "^2.27.2",
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-placeholder": "^2.11.5",
|
||||
"@tiptap/extension-table": "^2.27.2",
|
||||
"@tiptap/extension-table-cell": "^2.27.2",
|
||||
"@tiptap/extension-table-header": "^2.27.2",
|
||||
"@tiptap/extension-table-row": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.11.5",
|
||||
"@tiptap/extension-underline": "^2.11.5",
|
||||
"@tiptap/pm": "^2.11.5",
|
||||
@@ -1568,6 +1576,20 @@
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-color": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.27.2.tgz",
|
||||
"integrity": "sha512-sOKCP8/2V3sRM3FdWgMe1lFE5ewsWNCRafiVoujS1+TTHGCj4jw6W+LiumBUk7cRI8kXW/rqGWVC4RVdknYUCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/extension-text-style": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
|
||||
@@ -1652,6 +1674,19 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-highlight": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.27.2.tgz",
|
||||
"integrity": "sha512-ZjlktDdMjruMJFAVz0TbQf0v92Jqkc7Ri1iZJqBXuLid+r+GxUzl2CVAV7qq5yagkGQgvAG+WGsMk880HgR3MA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-history": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
|
||||
@@ -1680,6 +1715,19 @@
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz",
|
||||
"integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
|
||||
@@ -1693,6 +1741,23 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
|
||||
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
|
||||
@@ -1759,6 +1824,59 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.2.tgz",
|
||||
"integrity": "sha512-pDbhOpT5phZkcsyPjGBQlXv0+0hmdrvqHJ+dJjkGcCtlfy2pHiEIhmIItOFagc7wXy8G9iUFZ9Jie4zvDf+brg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-cell": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.2.tgz",
|
||||
"integrity": "sha512-9Lk46MjZMFzVZfOj9Kd7VgC6Odt6vmEhlCYVumErShUY7EkFqCw3b2IYoUtQkntfOEx/Afnhff/okNQwPsJeUA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-header": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.2.tgz",
|
||||
"integrity": "sha512-ZEb6lbG0NbbodWLV0b4BS/QrDIPlUbCcuOsUxzqVvlMUY1Vg6Fj6fKwLaBcsIUDHi8sxZDBEgYEDw3BR/zcO6A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-row": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.2.tgz",
|
||||
"integrity": "sha512-Nw9+tA56Y5HtLVP01NGCZSUuTQhJPtfK9OfmDgGgcxynn2cRVdEtj+9FNZqRhQ1iRVaAI+Rd4xRvX9qYePMOxw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
|
||||
@@ -2828,6 +2946,12 @@
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lottie-web": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||
|
||||
@@ -12,7 +12,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
"@tiptap/extension-color": "^2.27.2",
|
||||
"@tiptap/extension-highlight": "^2.27.2",
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-placeholder": "^2.11.5",
|
||||
"@tiptap/extension-table": "^2.27.2",
|
||||
"@tiptap/extension-table-cell": "^2.27.2",
|
||||
"@tiptap/extension-table-header": "^2.27.2",
|
||||
"@tiptap/extension-table-row": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.11.5",
|
||||
"@tiptap/extension-underline": "^2.11.5",
|
||||
"@tiptap/pm": "^2.11.5",
|
||||
|
||||
@@ -14,10 +14,15 @@ import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||
import { PetalFall } from './effects/PetalFall'
|
||||
import { useNightMode } from './hooks/useNightMode'
|
||||
import { playSuggestionSound } from './audio/sounds'
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionWatch()
|
||||
// Late-night calm mode: dark theme + falling stars instead of petals. The hook
|
||||
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
|
||||
// layer so the petals become stars.
|
||||
const night = useNightMode()
|
||||
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -192,6 +197,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)
|
||||
@@ -338,7 +375,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PetalFall />
|
||||
<PetalFall night={night} />
|
||||
<header
|
||||
onMouseDown={handleChromeDown}
|
||||
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
@@ -371,6 +408,7 @@ export default function App() {
|
||||
onSelect={openDoc}
|
||||
onCreate={handleCreate}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onToggleTag={handleToggleTag}
|
||||
onCreateTag={handleCreateTag}
|
||||
/>
|
||||
@@ -475,6 +513,7 @@ export default function App() {
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
llmDown={llmDown}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
text={docText}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -146,6 +147,10 @@ export const api = {
|
||||
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||
dismissSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||
// Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal
|
||||
// opening bubble (the explanation itself stays English in the card body).
|
||||
translateSuggestion: (id: string) =>
|
||||
req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),
|
||||
|
||||
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||
@@ -164,6 +169,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
|
||||
@@ -182,6 +191,21 @@ export const api = {
|
||||
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
|
||||
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// Image upload: posts a single image file as multipart form data and returns
|
||||
// its served URL (e.g. /api/images/<hash>.png). Stored on disk by the binary,
|
||||
// so the document JSON keeps a small URL reference instead of inlined base64.
|
||||
// Doesn't go through req() because the body is FormData, not JSON.
|
||||
uploadImage: async (file: File): Promise<{ url: string }> => {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const res = await fetch('/api/images', { method: 'POST', body: form })
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||
}
|
||||
return res.json() as Promise<{ url: string }>
|
||||
},
|
||||
|
||||
// Tags. listTags returns the roster with per-tag document counts; createTag is
|
||||
// idempotent on name (returns the existing tag if it already exists);
|
||||
// updateTag recolors/renames; deleteTag removes it (assignments cascade).
|
||||
|
||||
BIN
web/src/assets/sounds/baoding.mp3
Normal file
BIN
web/src/assets/sounds/baoding.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/block.mp3
Normal file
BIN
web/src/assets/sounds/block.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
web/src/assets/sounds/error.mp3
Normal file
BIN
web/src/assets/sounds/error.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/milestone.mp3
Normal file
BIN
web/src/assets/sounds/milestone.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
web/src/assets/sounds/pop1.mp3
Normal file
BIN
web/src/assets/sounds/pop1.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop2.mp3
Normal file
BIN
web/src/assets/sounds/pop2.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
web/src/assets/sounds/tok.mp3
Normal file
BIN
web/src/assets/sounds/tok.mp3
Normal file
Binary file not shown.
@@ -1,16 +1,18 @@
|
||||
// Petal's cute sound palette. The actual tones live as tiny WAV assets in
|
||||
// ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops,
|
||||
// and soft bells tuned to match the app's gentle look). Vite fingerprints and
|
||||
// bundles them into dist, so they ride along inside the single Go binary; here
|
||||
// we just fetch + decode them once and play them through a Web Audio bus with a
|
||||
// soft master volume. The whole thing is opt-out-able and the mute choice
|
||||
// persists in localStorage so it survives reloads.
|
||||
// Petal's cute sound palette. These are hand-picked mp3 effects (warm bubble
|
||||
// pops, a wooden "tok", rolling baoding balls, a Chinese fanfare for milestones,
|
||||
// and a playful "haiya" for errors) living as assets in ./assets/sounds. Vite
|
||||
// fingerprints and bundles them into dist, so they ride along inside the single
|
||||
// Go binary; here we just fetch + decode them once and play them through a Web
|
||||
// Audio bus with a soft master volume. The whole thing is opt-out-able and the
|
||||
// mute choice persists in localStorage so it survives reloads.
|
||||
|
||||
import popUrl from '../assets/sounds/pop.wav'
|
||||
import dropletUrl from '../assets/sounds/droplet.wav'
|
||||
import chimeUrl from '../assets/sounds/chime.wav'
|
||||
import shimmerUrl from '../assets/sounds/shimmer.wav'
|
||||
import cheerUrl from '../assets/sounds/cheer.wav'
|
||||
import pop1Url from '../assets/sounds/pop1.mp3'
|
||||
import pop2Url from '../assets/sounds/pop2.mp3'
|
||||
import tokUrl from '../assets/sounds/tok.mp3'
|
||||
import blockUrl from '../assets/sounds/block.mp3'
|
||||
import baodingUrl from '../assets/sounds/baoding.mp3'
|
||||
import milestoneUrl from '../assets/sounds/milestone.mp3'
|
||||
import errorUrl from '../assets/sounds/error.mp3'
|
||||
|
||||
const STORAGE_KEY = 'petal.sound'
|
||||
|
||||
@@ -18,20 +20,32 @@ const STORAGE_KEY = 'petal.sound'
|
||||
const MASTER_GAIN = 0.5
|
||||
|
||||
export type SoundName =
|
||||
| 'pop' // a soft bubble pop — the signature "something arrived"
|
||||
| 'droplet' // a single rounded water-drop ping
|
||||
| 'chime' // a gentle two-note bell
|
||||
| 'shimmer' // three quick ascending sparkles
|
||||
| 'cheer' // a happy little major arpeggio (accepts, milestones)
|
||||
| 'pop1' // a soft bubble pop
|
||||
| 'pop2' // a second bubble pop, slightly different
|
||||
| 'tok' // a light wooden "tok"
|
||||
| 'block' // a quick Chinese wood-block tap
|
||||
| 'baoding' // rolling baoding-ball shimmer
|
||||
| 'milestone' // a happy Chinese fanfare (word-count milestones)
|
||||
| 'error' // a playful "haiya" — something went wrong
|
||||
|
||||
const SOURCES: Record<SoundName, string> = {
|
||||
pop: popUrl,
|
||||
droplet: dropletUrl,
|
||||
chime: chimeUrl,
|
||||
shimmer: shimmerUrl,
|
||||
cheer: cheerUrl,
|
||||
pop1: pop1Url,
|
||||
pop2: pop2Url,
|
||||
tok: tokUrl,
|
||||
block: blockUrl,
|
||||
baoding: baodingUrl,
|
||||
milestone: milestoneUrl,
|
||||
error: errorUrl,
|
||||
}
|
||||
|
||||
// The pool of light "something happened" notification sounds. We rotate through
|
||||
// these (skipping the one just played) so the same blip never repeats back to
|
||||
// back — a batch of suggestions or a run of tips stays lively instead of
|
||||
// hammering one note. milestone/error are deliberately NOT in the pool: they're
|
||||
// reserved for their specific moments.
|
||||
const POP_POOL: SoundName[] = ['pop1', 'pop2', 'tok', 'block', 'baoding']
|
||||
let lastPop = -1
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
let master: GainNode | null = null
|
||||
const buffers = new Map<SoundName, AudioBuffer>()
|
||||
@@ -147,18 +161,24 @@ export function playSound(name: SoundName): void {
|
||||
})()
|
||||
}
|
||||
|
||||
// Map an editor suggestion type to its sound, so each kind of help has its own
|
||||
// little voice (grammar pops like a bubble, idioms chime, etc.).
|
||||
const SUGGESTION_SOUND: Record<string, SoundName> = {
|
||||
grammar: 'pop',
|
||||
phrasing: 'droplet',
|
||||
idiom: 'chime',
|
||||
clarity: 'shimmer',
|
||||
voice: 'droplet',
|
||||
// Play the next notification pop from the pool, never repeating the last one, so
|
||||
// consecutive blips always sound different. This is the everyday "something
|
||||
// arrived" voice for suggestions, tips, and gentle cheers.
|
||||
export function playPop(): void {
|
||||
let i = lastPop
|
||||
// Tiny pool, so a short scan to pick anything but the last is plenty.
|
||||
for (let tries = 0; tries < POP_POOL.length && i === lastPop; tries++) {
|
||||
i = Math.floor(Math.random() * POP_POOL.length)
|
||||
}
|
||||
lastPop = i
|
||||
playSound(POP_POOL[i])
|
||||
}
|
||||
|
||||
export function playSuggestionSound(type: string): void {
|
||||
playSound(SUGGESTION_SOUND[type] ?? 'pop')
|
||||
// Each freshly-surfaced suggestion just gets a rotating pop — the variety comes
|
||||
// from the pool, not from the suggestion type, so a batch of same-type fixes
|
||||
// still sounds lively.
|
||||
export function playSuggestionSound(_type: string): void {
|
||||
playPop()
|
||||
}
|
||||
|
||||
// Unlock + warm audio on the first user gesture so the very first sound isn't
|
||||
|
||||
115
web/src/audio/speech.ts
Normal file
115
web/src/audio/speech.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Read-aloud for an ESL writer: hear how an English word or passage sounds.
|
||||
//
|
||||
// Primary path is server-side neural TTS (Piper, proxied at /api/tts) — natural,
|
||||
// consistent across devices, and far better than the browser's built-in voices
|
||||
// (which fall back to robotic espeak on Chromium). If the server route is absent
|
||||
// (TTS disabled) or unreachable, we fall back to the browser's Web Speech API so
|
||||
// the buttons still do something. No model or network is strictly required.
|
||||
|
||||
// speechSupported reports whether read-aloud can do anything at all. Audio
|
||||
// playback is universal, so as long as we can construct an Audio element OR the
|
||||
// Web Speech API exists, the buttons should show. The server path is tried at
|
||||
// call time and degrades on its own.
|
||||
export function speechSupported(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return typeof window.Audio === 'function' || 'speechSynthesis' in window
|
||||
}
|
||||
|
||||
// webSpeechSupported gates the fallback path specifically.
|
||||
function webSpeechSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
|
||||
}
|
||||
|
||||
// current holds the in-flight server-audio element and its object URL so a rapid
|
||||
// re-tap can stop and replace it (mirroring speechSynthesis.cancel()).
|
||||
let current: { audio: HTMLAudioElement; url: string } | null = null
|
||||
|
||||
// requestSeq increments on every speak() call so a slow fetch that resolves after
|
||||
// a newer tap can detect it's stale and bow out instead of double-playing.
|
||||
let requestSeq = 0
|
||||
|
||||
function stopCurrent(): void {
|
||||
if (current) {
|
||||
current.audio.pause()
|
||||
URL.revokeObjectURL(current.url)
|
||||
current = null
|
||||
}
|
||||
if (webSpeechSupported()) window.speechSynthesis.cancel()
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch
|
||||
// slower than default so learners can follow along.
|
||||
function speakWebSpeech(text: string, lang: string): void {
|
||||
if (!webSpeechSupported()) 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)
|
||||
}
|
||||
|
||||
// detectLang guesses a BCP-47 locale from the text so the right Piper voice is
|
||||
// chosen. Any Han/kana character routes to Chinese (zh-CN) — a selection is
|
||||
// essentially never mixed at the level that matters, and reading a Chinese
|
||||
// passage with the English voice spells each character out ("Chinese letter…").
|
||||
// Everything else defaults to US English, the language she's learning.
|
||||
const CJK = /[-ヿ㐀-䶿一-鿿豈-ヲ-゚]/
|
||||
export function detectLang(text: string): string {
|
||||
return CJK.test(text) ? 'zh-CN' : 'en-US'
|
||||
}
|
||||
|
||||
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
|
||||
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
|
||||
// so callers can just pass the selection; pass an explicit locale to override.
|
||||
// It tries the server's neural voice first and silently falls back to the browser
|
||||
// voice if that's unavailable (route off, network error, or a 404 for a language
|
||||
// with no configured voice).
|
||||
export function speak(text: string, lang = detectLang(text)): void {
|
||||
if (!text.trim()) return
|
||||
stopCurrent()
|
||||
const seq = ++requestSeq
|
||||
|
||||
fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, lang }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`tts ${res.status}`)
|
||||
return res.blob()
|
||||
})
|
||||
.then((blob) => {
|
||||
// A newer tap superseded this one while the fetch was in flight — drop it.
|
||||
if (seq !== requestSeq) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const audio = new Audio(url)
|
||||
current = { audio, url }
|
||||
// Release the blob once playback finishes (or errors) to avoid leaking.
|
||||
const cleanup = () => {
|
||||
if (current?.audio === audio) {
|
||||
URL.revokeObjectURL(url)
|
||||
current = null
|
||||
}
|
||||
}
|
||||
audio.addEventListener('ended', cleanup)
|
||||
audio.addEventListener('error', cleanup)
|
||||
return audio.play()
|
||||
})
|
||||
.catch(() => {
|
||||
// Server TTS unavailable for this request — use the browser voice instead,
|
||||
// unless a newer tap has already superseded this one.
|
||||
if (seq !== requestSeq) return
|
||||
speakWebSpeech(text, lang)
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,8 @@ interface Props {
|
||||
animationData?: object
|
||||
loop?: boolean
|
||||
className?: string
|
||||
// Mirror the animation left↔right (for assets drawn facing the wrong way).
|
||||
flip?: boolean
|
||||
fallback: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
|
||||
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
||||
// offline). Reloads the animation whenever the data changes — moods swap by
|
||||
// passing a different `animationData`.
|
||||
export function LottiePlayer({ animationData, loop = true, className, fallback }: Props) {
|
||||
export function LottiePlayer({ animationData, loop = true, className, flip, fallback }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const anim = useRef<AnimationItem | null>(null)
|
||||
|
||||
@@ -74,5 +76,12 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
|
||||
}, [animationData, loop])
|
||||
|
||||
if (!animationData) return <>{fallback}</>
|
||||
return <div ref={ref} className={className} aria-hidden />
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={flip ? { transform: 'scaleX(-1)' } : undefined}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { COMPANIONS, DEFAULT_COMPANION } from './companions'
|
||||
interface Props {
|
||||
wordCount: number
|
||||
saveStatus: SaveStatus
|
||||
llmDown: boolean
|
||||
editTick: number
|
||||
acceptTick: number
|
||||
text: string
|
||||
@@ -27,10 +28,11 @@ const STORAGE_KEY = 'petal.companion'
|
||||
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
|
||||
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||
// (the choice persists in localStorage).
|
||||
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Props) {
|
||||
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
|
||||
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||
wordCount,
|
||||
saveStatus,
|
||||
llmDown,
|
||||
editTick,
|
||||
acceptTick,
|
||||
text,
|
||||
@@ -145,7 +147,7 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, te
|
||||
onClick={dismiss}
|
||||
onMouseEnter={holdBubble}
|
||||
onMouseLeave={releaseBubble}
|
||||
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
|
||||
className="petal-bubble pointer-events-auto max-w-[420px] cursor-pointer p-4 pr-5"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
@@ -156,10 +158,16 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, te
|
||||
}}
|
||||
title="Click to dismiss"
|
||||
>
|
||||
<p className="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
<p
|
||||
className="font-bold leading-snug"
|
||||
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
|
||||
>
|
||||
{bubble.zh}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
<p
|
||||
className="mt-0.5 leading-snug"
|
||||
style={{ color: 'var(--color-muted)', fontSize: '1.15rem' }}
|
||||
>
|
||||
{bubble.en}
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,8 +180,9 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, te
|
||||
aria-label="Choose a companion"
|
||||
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
||||
style={{
|
||||
width: 144,
|
||||
height: 144,
|
||||
// Size scales with the viewport — see --petal-companion-size in index.css.
|
||||
width: 'var(--petal-companion-size)',
|
||||
height: 'var(--petal-companion-size)',
|
||||
padding: 0,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface-alt)',
|
||||
@@ -187,8 +196,13 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, te
|
||||
<LottiePlayer
|
||||
key={companion.id}
|
||||
animationData={animationData}
|
||||
className="h-32 w-32"
|
||||
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
||||
flip={companion.flip}
|
||||
className="petal-companion-art"
|
||||
fallback={
|
||||
<span style={{ fontSize: 'calc(var(--petal-companion-size) * 0.5)', lineHeight: 1 }}>
|
||||
{MOOD_EMOJI[renderMood]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{napping && (
|
||||
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||
|
||||
1
web/src/components/Companion/animations/butterfly.json
Normal file
1
web/src/components/Companion/animations/butterfly.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/parrot.json
Normal file
1
web/src/components/Companion/animations/parrot.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
||||
import type { Mood } from './useCompanion'
|
||||
import sleepingCat from './animations/sleeping-cat.json'
|
||||
import happyDog from './animations/happy-dog.json'
|
||||
import wiggleDog from './animations/wiggle-dog.json'
|
||||
import butterfly from './animations/butterfly.json'
|
||||
import parrot from './animations/parrot.json'
|
||||
|
||||
export interface Companion {
|
||||
id: string
|
||||
@@ -12,6 +15,9 @@ export interface Companion {
|
||||
// When true the mascot keeps its calm sway + drifting zzz regardless of mood
|
||||
// (e.g. a cat that's always asleep but still talks in its sleep).
|
||||
alwaysAsleep?: boolean
|
||||
// When true the rendered animation is mirrored left↔right — for assets drawn
|
||||
// facing the "wrong" way for our bottom-right corner (e.g. the parrot).
|
||||
flip?: boolean
|
||||
}
|
||||
|
||||
// The roster. Add a companion by dropping a pure-vector Lottie JSON into
|
||||
@@ -44,6 +50,43 @@ export const COMPANIONS: Companion[] = [
|
||||
// no sleeping clip → stays in its idle pose instead of visibly napping
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wiggle-dog',
|
||||
name: 'Wiggle Dog',
|
||||
zh: '摇尾狗',
|
||||
emoji: '🐕',
|
||||
animations: {
|
||||
idle: wiggleDog,
|
||||
happy: wiggleDog,
|
||||
talking: wiggleDog,
|
||||
celebrate: wiggleDog,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'butterfly',
|
||||
name: 'Butterfly',
|
||||
zh: '蝴蝶',
|
||||
emoji: '🦋',
|
||||
animations: {
|
||||
idle: butterfly,
|
||||
happy: butterfly,
|
||||
talking: butterfly,
|
||||
celebrate: butterfly,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'parrot',
|
||||
name: 'Parrot',
|
||||
zh: '鹦鹉',
|
||||
emoji: '🦜',
|
||||
flip: true, // asset faces left; mirror it to face into the page
|
||||
animations: {
|
||||
idle: parrot,
|
||||
happy: parrot,
|
||||
talking: parrot,
|
||||
celebrate: parrot,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_COMPANION = 'cat'
|
||||
|
||||
@@ -15,6 +15,11 @@ export const ENCOURAGEMENTS: Line[] = [
|
||||
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
||||
{ zh: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
||||
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||
{ zh: '这个词用得真好 🌷', en: 'That’s such a good word choice.' },
|
||||
{ zh: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||
{ zh: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
|
||||
{ zh: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
|
||||
{ zh: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
|
||||
]
|
||||
|
||||
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
|
||||
@@ -36,6 +41,23 @@ export const BREAKS: Line[] = [
|
||||
{ zh: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||
]
|
||||
|
||||
// Shown when she's still writing late at night (≥11pm). Caring, a little
|
||||
// playful — the kitten is always asleep, so "you should be too" lands as a gag,
|
||||
// never a scold. zh stays gentle; the English subtitle carries the wink.
|
||||
export const BEDTIME: Line[] = [
|
||||
{ zh: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||
{ zh: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||
{ zh: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
|
||||
{ zh: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
// A few old Chinese proverbs on sleep (zh = a faithful rendering of the
|
||||
// English sense — the verified classical 原文 isn't recoverable; swap in the
|
||||
// exact source text if you have it). They read a touch wittier/wiser than the
|
||||
// gentle lines above, which suits a late-night nudge.
|
||||
{ zh: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
|
||||
{ zh: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' },
|
||||
{ zh: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
|
||||
]
|
||||
|
||||
// First hello when the app opens.
|
||||
export const GREETING: Line = {
|
||||
zh: '嗨~我在这儿陪你写作哦 🐱',
|
||||
@@ -48,8 +70,16 @@ export const WELCOME_BACK: Line = {
|
||||
en: 'Welcome back ✨ let’s keep going!',
|
||||
}
|
||||
|
||||
// Word-count milestones worth a little cheer.
|
||||
export const MILESTONES = [50, 100, 250, 500, 1000, 2000]
|
||||
// Gentle "haiya, something went wrong" lines — paired with the error sound when
|
||||
// the LLM is unreachable or a save fails. Never alarming, always reassuring.
|
||||
export const ERRORS: Line[] = [
|
||||
{ zh: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||
{ zh: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||
{ zh: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||
]
|
||||
|
||||
// Word-count milestones worth a little cheer — every 100 words, on up.
|
||||
export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100)
|
||||
|
||||
export function milestoneLine(n: number): Line {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import {
|
||||
BEDTIME,
|
||||
BREAKS,
|
||||
ENCOURAGEMENTS,
|
||||
ERRORS,
|
||||
GREETING,
|
||||
MILESTONES,
|
||||
TIPS,
|
||||
@@ -12,13 +14,14 @@ import {
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
import { playSound } from '../../audio/sounds'
|
||||
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
||||
import { isBedtime } from '../../lib/night'
|
||||
|
||||
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
||||
|
||||
export type BubbleTone = 'cheer' | 'tip' | 'break'
|
||||
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime'
|
||||
export interface Bubble extends Line {
|
||||
tone: BubbleTone
|
||||
}
|
||||
@@ -26,6 +29,9 @@ export interface Bubble extends Line {
|
||||
interface Signals {
|
||||
wordCount: number
|
||||
saveStatus: SaveStatus
|
||||
// True while the writing-assist LLM is unreachable (timeout / down). A
|
||||
// false→true flip earns a gentle "haiya".
|
||||
llmDown: boolean
|
||||
// Monotonic counters bumped by the app on each editor change / accepted
|
||||
// suggestion — lets the companion react without a full event bus.
|
||||
editTick: number
|
||||
@@ -40,13 +46,16 @@ const IDLE_MS = 75_000 // no edits → kitten naps
|
||||
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
|
||||
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
|
||||
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||
const BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window
|
||||
// The late-night window itself (isBedtime) lives in ../../lib/night so the
|
||||
// companion nag and the night-mode theme/starfall share one definition.
|
||||
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||
// how much there is to read, since she reads both the Mandarin and the English
|
||||
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||
const BUBBLE_MS = 9_000 // baseline for a tip / break
|
||||
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
|
||||
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
|
||||
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
|
||||
const BUBBLE_MS = 14_000 // baseline for a tip / break
|
||||
const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
|
||||
const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
|
||||
const MAX_BUBBLE_MS = 32_000 // cap so a long quote can't pin the bubble forever
|
||||
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||
const now = () => Date.now()
|
||||
|
||||
@@ -54,7 +63,7 @@ const now = () => Date.now()
|
||||
// length of the Mandarin + English lines so denser advice stays up long enough
|
||||
// to actually finish reading.
|
||||
function readBubbleMs(b: Bubble): number {
|
||||
const base = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||
const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
|
||||
const chars = b.zh.length + b.en.length
|
||||
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
|
||||
}
|
||||
@@ -62,7 +71,7 @@ function readBubbleMs(b: Bubble): number {
|
||||
// useCompanion is the behavior engine: it watches writing signals and decides
|
||||
// when the kitten speaks, what mood it shows, and how to pace itself so the
|
||||
// companion feels alive without interrupting. UI-agnostic — returns state only.
|
||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) {
|
||||
export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Signals) {
|
||||
const [mood, setMood] = useState<Mood>('idle')
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||
|
||||
@@ -71,6 +80,7 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
const lastProactive = useRef(0)
|
||||
const lastTip = useRef(0)
|
||||
const lastBreak = useRef(0)
|
||||
const lastBedtime = useRef(0)
|
||||
const nextMilestone = useRef(0) // index into MILESTONES
|
||||
const sleeping = useRef(false)
|
||||
|
||||
@@ -88,7 +98,8 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
|
||||
// Show a bubble + talking mood, then settle back. `proactive` messages respect
|
||||
// the spacing floor; user-triggered ones (cheers) always go through.
|
||||
const say = useCallback((b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean }) => {
|
||||
const say = useCallback(
|
||||
(b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean; sound?: SoundName }) => {
|
||||
const t = now()
|
||||
if (opts?.proactive) {
|
||||
if (bubble) return
|
||||
@@ -96,11 +107,11 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
lastProactive.current = t
|
||||
}
|
||||
sleeping.current = false
|
||||
// A soft sound to match the bubble's mood — celebrations cheer, gentle
|
||||
// cheers sparkle, break nudges chime, and tips give a little bubble pop.
|
||||
playSound(
|
||||
opts?.celebrate ? 'cheer' : b.tone === 'cheer' ? 'shimmer' : b.tone === 'break' ? 'chime' : 'pop',
|
||||
)
|
||||
// A sound to match the bubble: callers can name a specific one (the milestone
|
||||
// fanfare, the "haiya" on errors); everything else gets a rotating pop so the
|
||||
// same blip never repeats back to back.
|
||||
if (opts?.sound) playSound(opts.sound)
|
||||
else playPop()
|
||||
setBubble(b)
|
||||
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||
clearTimeout(bubbleTimer.current)
|
||||
@@ -196,11 +207,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
const n = MILESTONES[nextMilestone.current]
|
||||
nextMilestone.current += 1
|
||||
// Skip silently if we're just loading a long doc (no edits yet).
|
||||
if (!firstEdit.current) say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true })
|
||||
if (!firstEdit.current)
|
||||
say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true, sound: 'milestone' })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wordCount])
|
||||
|
||||
// Trouble — a save failed or the writing-assist LLM went unreachable. Play the
|
||||
// playful "haiya" and reassure her, but never spam: at most one per gap, and
|
||||
// only on the *transition* into trouble (not while it lingers).
|
||||
const lastError = useRef(0)
|
||||
const ERROR_GAP = 20_000
|
||||
const wasLlmDown = useRef(false)
|
||||
const haiya = useCallback(() => {
|
||||
const t = now()
|
||||
if (t - lastError.current < ERROR_GAP) {
|
||||
playSound('error') // still acknowledge it, just without a fresh bubble
|
||||
return
|
||||
}
|
||||
lastError.current = t
|
||||
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
|
||||
}, [say])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'error') haiya()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [saveStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (llmDown && !wasLlmDown.current) haiya()
|
||||
wasLlmDown.current = llmDown
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [llmDown])
|
||||
|
||||
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
||||
@@ -229,6 +268,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
return
|
||||
}
|
||||
|
||||
// Burning the midnight oil? Gently suggest bed — caring, low-frequency,
|
||||
// and only while she's actually still at it (the idle branch above already
|
||||
// returned if she's away/napping).
|
||||
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
|
||||
lastBedtime.current = t
|
||||
say({ ...pick(BEDTIME), tone: 'bedtime' }, { proactive: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise an occasional tip — context-aware when her text gives us
|
||||
// something concrete to gently point at, generic warmth otherwise.
|
||||
if (t - lastTip.current > TIP_MIN_GAP) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||
|
||||
interface Props {
|
||||
suggestionId: string
|
||||
// Pre-populates Petal's first bubble so the conversation opens with context.
|
||||
// The English explanation (shown in the card body). Petal's opening bubble is
|
||||
// its Simplified-Chinese translation, fetched on open — so the panel doesn't
|
||||
// just repeat the same English text twice. Falls back to this on failure.
|
||||
explanation: string
|
||||
}
|
||||
|
||||
@@ -17,9 +19,10 @@ const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC
|
||||
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
||||
// token into the latest assistant bubble.
|
||||
export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{ role: 'assistant', content: explanation },
|
||||
])
|
||||
// Opening bubble starts empty (caret-only) and fills with the Mandarin
|
||||
// translation once it lands; `seeding` drives that loading caret.
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
||||
const [seeding, setSeeding] = useState(true)
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -36,6 +39,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Fetch the Chinese translation of the explanation to seed the first bubble.
|
||||
// Only replaces the seed bubble if the user hasn't started chatting yet (the
|
||||
// conversation always opens with this one assistant turn). Falls back to the
|
||||
// English explanation if the translation can't be fetched.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api
|
||||
.translateSuggestion(suggestionId)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const text = res.translation.trim() || explanation
|
||||
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSeeding(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [suggestionId, explanation])
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
@@ -86,7 +114,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
style={{ maxHeight: 220 }}
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
|
||||
<Bubble
|
||||
key={i}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
streaming={(streaming && i === messages.length - 1) || (seeding && i === 0)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,17 +4,33 @@ import Underline from '@tiptap/extension-underline'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import TextStyle from '@tiptap/extension-text-style'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import { FontSize } from './FontSize'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Toolbar } from '../Toolbar/Toolbar'
|
||||
import { SuggestionCard } from './SuggestionCard'
|
||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||
import { SuggestionRail, type RailItem } from './SuggestionRail'
|
||||
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
|
||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||
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 {
|
||||
@@ -103,6 +119,21 @@ function parseDoc(raw: string): object | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// uploadImageInto sends an image file to the store and inserts the returned URL
|
||||
// as an image node — at `pos` if given (a drop point), otherwise at the current
|
||||
// selection (a paste). Shared by the paste/drop handlers and the toolbar button.
|
||||
export async function uploadImageInto(view: EditorView, file: File, pos?: number) {
|
||||
try {
|
||||
const { url } = await api.uploadImage(file)
|
||||
const { schema } = view.state
|
||||
const node = schema.nodes.image.create({ src: url })
|
||||
const at = pos ?? view.state.selection.from
|
||||
view.dispatch(view.state.tr.insert(at, node))
|
||||
} catch (err) {
|
||||
console.error('image upload failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
interface HoverState {
|
||||
suggestion: Suggestion
|
||||
top: number
|
||||
@@ -181,22 +212,73 @@ 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)
|
||||
// The right-margin comment rail. `railItems` carries each anchored suggestion's
|
||||
// vertical offset; `railEnabled` is true only when the viewport has room for the
|
||||
// column beside the editor (otherwise we fall back to the inline hover cards).
|
||||
// `railExpandedId` is the card showing its full explanation + Ask Petal, and
|
||||
// `activeId` is the suggestion currently emphasized (hovered text or card).
|
||||
const [railItems, setRailItems] = useState<RailItem[]>([])
|
||||
const [railEnabled, setRailEnabled] = useState(false)
|
||||
const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
// A stable handle to the latest recompute so the editor's onUpdate (captured
|
||||
// once at construction) can trigger a re-measure without stale closures.
|
||||
const recomputeRailRef = useRef<() => void>(() => {})
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
TextStyle,
|
||||
Color,
|
||||
FontSize,
|
||||
Highlight.configure({ multicolor: true }),
|
||||
Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: 'noopener noreferrer nofollow' } }),
|
||||
Image.configure({ inline: false, HTMLAttributes: { class: 'petal-image' } }),
|
||||
Table.configure({ resizable: true, HTMLAttributes: { class: 'petal-table' } }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||
CharacterCount,
|
||||
SuggestionHighlight,
|
||||
SpellCheck,
|
||||
SearchHighlight,
|
||||
Typography,
|
||||
],
|
||||
content: parseDoc(initialContent),
|
||||
editorProps: {
|
||||
attributes: { class: 'petal-prose focus:outline-none' },
|
||||
// Dropping or pasting an image file uploads it and inserts it at the drop
|
||||
// point (or the cursor for a paste). Returns true to consume the event so
|
||||
// ProseMirror doesn't also try to handle the raw file. Non-image pastes
|
||||
// fall through to the default handler.
|
||||
handleDrop: (view, event) => {
|
||||
const files = (event as DragEvent).dataTransfer?.files
|
||||
const image = files && Array.from(files).find((f) => f.type.startsWith('image/'))
|
||||
if (!image) return false
|
||||
event.preventDefault()
|
||||
const coords = view.posAtCoords({ left: (event as DragEvent).clientX, top: (event as DragEvent).clientY })
|
||||
uploadImageInto(view, image, coords?.pos)
|
||||
return true
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
const files = event.clipboardData?.files
|
||||
const image = files && Array.from(files).find((f) => f.type.startsWith('image/'))
|
||||
if (!image) return false
|
||||
event.preventDefault()
|
||||
uploadImageInto(view, image)
|
||||
return true
|
||||
},
|
||||
},
|
||||
onFocus: () => onFocusMode?.(),
|
||||
onUpdate: ({ editor }) => {
|
||||
@@ -213,6 +295,8 @@ export function EditorCore({
|
||||
content_text: editor.getText(),
|
||||
word_count: editor.storage.characterCount.words(),
|
||||
})
|
||||
// Edits reflow the text, so the rail anchors need re-measuring.
|
||||
recomputeRailRef.current()
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
// A selection supersedes the hover gloss; an empty one clears the bubble.
|
||||
@@ -264,8 +348,87 @@ export function EditorCore({
|
||||
setSuggestions(editor.state, editor.view.dispatch, suggestions)
|
||||
// Close the card if its suggestion is gone.
|
||||
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
|
||||
// Drop any rail expand/emphasis that points at a now-removed suggestion.
|
||||
setRailExpandedId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
|
||||
setActiveId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
|
||||
}, [editor, suggestions])
|
||||
|
||||
// Re-measure the margin rail: whether there's room for the column beside the
|
||||
// editor, and where each suggestion's highlight sits vertically. Anchors are
|
||||
// taken from the live decoration DOM (keyed by data-suggestion-id) relative to
|
||||
// the wrapper — stable under scroll since text and wrapper scroll together.
|
||||
// Deferred a frame so decoration DOM and reflow have settled.
|
||||
const recomputeRail = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
|
||||
// a little breathing space to the viewport edge.
|
||||
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
|
||||
const seen = new Set<string>()
|
||||
const items: RailItem[] = []
|
||||
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
|
||||
const id = el.getAttribute('data-suggestion-id')
|
||||
if (!id || seen.has(id)) return
|
||||
const s = suggestions.find((x) => x.id === id)
|
||||
if (!s) return
|
||||
seen.add(id)
|
||||
items.push({ suggestion: s, anchorTop: el.getBoundingClientRect().top - wrapRect.top })
|
||||
})
|
||||
setRailItems(items)
|
||||
})
|
||||
}, [suggestions])
|
||||
|
||||
useEffect(() => {
|
||||
recomputeRailRef.current = recomputeRail
|
||||
}, [recomputeRail])
|
||||
|
||||
// Re-anchor when the suggestion set changes (after the decorations repaint),
|
||||
// and keep the rail in sync with viewport/editor width changes (room + reflow).
|
||||
useEffect(() => {
|
||||
recomputeRail()
|
||||
const wrapper = wrapperRef.current
|
||||
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
|
||||
if (wrapper && ro) ro.observe(wrapper)
|
||||
window.addEventListener('resize', recomputeRail)
|
||||
return () => {
|
||||
ro?.disconnect()
|
||||
window.removeEventListener('resize', recomputeRail)
|
||||
}
|
||||
}, [recomputeRail])
|
||||
|
||||
// Emphasize the flagged text for the active suggestion, mirroring the rail
|
||||
// card ↔ text link both ways. Driven through the decoration plugin (not an
|
||||
// imperative DOM class) so it survives the repaints that fire on every edit.
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
setActiveSuggestion(editor.state, editor.view.dispatch, railEnabled ? activeId : null)
|
||||
}, [editor, activeId, railEnabled])
|
||||
|
||||
// Scroll a suggestion's highlight to the middle of the viewport (clicking its
|
||||
// rail card jumps the editor to the flagged text).
|
||||
const scrollToSuggestion = useCallback((id: string) => {
|
||||
const el = wrapperRef.current?.querySelector(
|
||||
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
|
||||
)
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
// Clicking a rail card's body jumps to the text and toggles its detail panel.
|
||||
const activateRailCard = useCallback(
|
||||
(id: string) => {
|
||||
setActiveId(id)
|
||||
scrollToSuggestion(id)
|
||||
setRailExpandedId((cur) => (cur === id ? null : id))
|
||||
},
|
||||
[scrollToSuggestion],
|
||||
)
|
||||
|
||||
const toggleRailExpand = useCallback((id: string) => {
|
||||
setRailExpandedId((cur) => (cur === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const openCardFor = useCallback(
|
||||
(id: string, el: HTMLElement) => {
|
||||
const wrapper = wrapperRef.current
|
||||
@@ -297,10 +460,17 @@ export function EditorCore({
|
||||
if (!target) return
|
||||
const id = target.getAttribute('data-suggestion-id')
|
||||
if (!id) return
|
||||
// With the rail open the card already lives in the margin — hovering the
|
||||
// text just emphasizes its card (and the highlight) rather than popping a
|
||||
// second, redundant floating card.
|
||||
if (railEnabled) {
|
||||
setActiveId(id)
|
||||
return
|
||||
}
|
||||
clearTimeout(closeTimer.current)
|
||||
openCardFor(id, target)
|
||||
},
|
||||
[openCardFor],
|
||||
[openCardFor, railEnabled],
|
||||
)
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
@@ -320,35 +490,53 @@ export function EditorCore({
|
||||
// pointer can bridge the small gap from text to card.
|
||||
const handleMouseOut = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose()
|
||||
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
|
||||
if (railEnabled) {
|
||||
setActiveId(null)
|
||||
return
|
||||
}
|
||||
scheduleClose()
|
||||
},
|
||||
[scheduleClose],
|
||||
[scheduleClose, railEnabled],
|
||||
)
|
||||
|
||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||
|
||||
// Accept applies the replacement to the document, plays a little confetti
|
||||
// burst where the card sat, then notifies the parent.
|
||||
// burst over the flagged text, then notifies the parent. The confetti is
|
||||
// anchored to the highlight itself (captured before the replacement removes it),
|
||||
// so it fires in the right spot whether the accept came from the hover card or
|
||||
// the margin rail.
|
||||
const handleAccept = useCallback(
|
||||
(s: Suggestion) => {
|
||||
const wrapper = wrapperRef.current
|
||||
const el = wrapper?.querySelector(
|
||||
`.petal-suggestion[data-suggestion-id="${CSS.escape(s.id)}"]`,
|
||||
) as HTMLElement | null
|
||||
let burst: { top: number; left: number } | null = null
|
||||
if (wrapper && el) {
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const elRect = el.getBoundingClientRect()
|
||||
burst = { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
|
||||
}
|
||||
if (editor && s.replacement.trim() !== '') {
|
||||
const range = findRange(editor.state.doc, s.original)
|
||||
if (range) {
|
||||
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
||||
}
|
||||
}
|
||||
setHover((h) => {
|
||||
if (h) {
|
||||
setConfetti({ top: h.top, left: h.left + 16 })
|
||||
clearTimeout(confettiTimer.current)
|
||||
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
||||
}
|
||||
return h
|
||||
})
|
||||
// Fall back to the hover card's position if the highlight wasn't found.
|
||||
if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
|
||||
if (burst) {
|
||||
setConfetti(burst)
|
||||
clearTimeout(confettiTimer.current)
|
||||
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
||||
}
|
||||
closeCard()
|
||||
setRailExpandedId(null)
|
||||
onAccept(s)
|
||||
},
|
||||
[editor, onAccept, closeCard],
|
||||
[editor, onAccept, closeCard, hover],
|
||||
)
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
@@ -370,7 +558,16 @@ export function EditorCore({
|
||||
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||
if (suggestionEl) {
|
||||
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||
if (id) openCardFor(id, suggestionEl)
|
||||
if (id) {
|
||||
// With the rail open, a tap emphasizes and expands its margin card
|
||||
// instead of opening a floating one.
|
||||
if (railEnabled) {
|
||||
setActiveId(id)
|
||||
setRailExpandedId(id)
|
||||
} else {
|
||||
openCardFor(id, suggestionEl)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!editor || !spellChecker) return
|
||||
@@ -392,7 +589,7 @@ export function EditorCore({
|
||||
setWordInfo(null)
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
},
|
||||
[editor, spellChecker, closeCard, openCardFor],
|
||||
[editor, spellChecker, closeCard, openCardFor, railEnabled],
|
||||
)
|
||||
|
||||
const replaceMisspelling = useCallback(
|
||||
@@ -410,20 +607,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)
|
||||
@@ -454,6 +648,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) {
|
||||
@@ -626,10 +851,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 —
|
||||
@@ -667,16 +934,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 && (
|
||||
@@ -709,7 +982,7 @@ export function EditorCore({
|
||||
onAdd={addMisspellingToDict}
|
||||
/>
|
||||
)}
|
||||
{hover && (
|
||||
{hover && !railEnabled && (
|
||||
<SuggestionCard
|
||||
suggestion={hover.suggestion}
|
||||
style={{ top: hover.top, left: hover.left }}
|
||||
@@ -720,6 +993,18 @@ export function EditorCore({
|
||||
onExpandChange={setPinned}
|
||||
/>
|
||||
)}
|
||||
{railEnabled && railItems.length > 0 && (
|
||||
<SuggestionRail
|
||||
items={railItems}
|
||||
activeId={activeId}
|
||||
expandedId={railExpandedId}
|
||||
onAccept={handleAccept}
|
||||
onDismiss={handleDismiss}
|
||||
onHover={setActiveId}
|
||||
onActivate={activateRailCard}
|
||||
onToggleExpand={toggleRailExpand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
51
web/src/components/Editor/FontSize.ts
Normal file
51
web/src/components/Editor/FontSize.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
// FontSize adds a `fontSize` attribute to the textStyle mark so a writer can
|
||||
// pick a size preset (Small / Normal / Large / Title) from the toolbar. It rides
|
||||
// on TextStyle (already loaded) rather than introducing a new mark, so it stacks
|
||||
// cleanly with color and other inline styling.
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
fontSize: {
|
||||
setFontSize: (size: string) => ReturnType
|
||||
unsetFontSize: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FontSize = Extension.create({
|
||||
name: 'fontSize',
|
||||
|
||||
addOptions() {
|
||||
return { types: ['textStyle'] }
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
fontSize: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.style.fontSize || null,
|
||||
renderHTML: (attributes) =>
|
||||
attributes.fontSize ? { style: `font-size: ${attributes.fontSize}` } : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setFontSize:
|
||||
(size) =>
|
||||
({ chain }) =>
|
||||
chain().setMark('textStyle', { fontSize: size }).run(),
|
||||
unsetFontSize:
|
||||
() =>
|
||||
({ chain }) =>
|
||||
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(),
|
||||
}
|
||||
},
|
||||
})
|
||||
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 {
|
||||
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`}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { Suggestion, SuggestionType } from '../../api/client'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
import { AskPetal } from './AskPetal'
|
||||
|
||||
// Per-type accent color + human label, mirroring the design tokens.
|
||||
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
||||
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
||||
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||
}
|
||||
import { TYPE_META } from './suggestionMeta'
|
||||
|
||||
interface Props {
|
||||
suggestion: Suggestion
|
||||
|
||||
54
web/src/components/Editor/SuggestionHighlight.test.ts
Normal file
54
web/src/components/Editor/SuggestionHighlight.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { foldTypography, findRange } from './SuggestionHighlight'
|
||||
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
// A minimal doc/paragraph/text schema, enough to exercise findRange's textblock
|
||||
// walk without pulling in the full editor.
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
|
||||
text: { group: 'inline' },
|
||||
},
|
||||
})
|
||||
|
||||
// Build a single-paragraph doc with the given plaintext.
|
||||
const para = (text: string): PMNode => schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
|
||||
|
||||
describe('foldTypography', () => {
|
||||
it('folds curly quotes back to straight ASCII', () => {
|
||||
expect(foldTypography('“He’s here,” she said').folded).toBe('"He\'s here," she said')
|
||||
})
|
||||
|
||||
it('folds dashes and ellipsis, keeping a source-index map across length changes', () => {
|
||||
const { folded, map } = foldTypography('wait—really...')
|
||||
expect(folded).toBe('wait—really…')
|
||||
// The em dash is already one glyph; the `...` collapsed 3 source chars to 1,
|
||||
// so the trailing sentinel still points just past the last source char.
|
||||
expect(map[map.length - 1]).toBe('wait—really...'.length)
|
||||
})
|
||||
|
||||
it('treats `--` and `...` typed-but-unconverted as their canonical glyphs', () => {
|
||||
expect(foldTypography('a--b...c').folded).toBe('a—b…c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findRange typographic anchoring', () => {
|
||||
it('anchors a model echo with straight quotes onto curly-quote document text', () => {
|
||||
const doc = para('She said “hello” to me')
|
||||
const range = findRange(doc, '“hello”'.replace(/[“”]/g, '"')) // model echoed "hello"
|
||||
expect(range).not.toBeNull()
|
||||
// The matched span must cover the curly-quoted region in the real document.
|
||||
expect(doc.textBetween(range!.from - 1, range!.to - 1)).toContain('hello')
|
||||
})
|
||||
|
||||
it('anchors an ASCII `--` echo onto an em-dash in the document', () => {
|
||||
const doc = para('I waited—forever')
|
||||
const range = findRange(doc, 'waited--forever')
|
||||
expect(range).not.toBeNull()
|
||||
})
|
||||
|
||||
it('still returns null when the text genuinely is not present', () => {
|
||||
expect(findRange(para('nothing to see'), 'absent phrase')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions'
|
||||
|
||||
interface PluginState {
|
||||
suggestions: Suggestion[]
|
||||
// The suggestion currently emphasized from its margin card / a hover, or null.
|
||||
// Carried in plugin state (not an imperative DOM class) so it survives the
|
||||
// decoration repaints that fire on every document change.
|
||||
activeId: string | null
|
||||
decorations: DecorationSet
|
||||
}
|
||||
|
||||
// Meta carried on a transaction to update the plugin: either a fresh suggestion
|
||||
// list or a change to which suggestion is emphasized.
|
||||
type SuggestionMeta = { suggestions: Suggestion[] } | { activeId: string | null }
|
||||
|
||||
// mapOffset converts a character offset within a textblock's flattened text into
|
||||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||
// breaks) that occupy a position but contribute no text.
|
||||
@@ -42,21 +50,87 @@ export function mapOffset(block: PMNode, blockPos: number, targetOffset: number)
|
||||
return result
|
||||
}
|
||||
|
||||
// foldTypography canonicalizes the typographic variants that the editor's
|
||||
// Typography input rules introduce (curly quotes, em/en dashes, single-glyph
|
||||
// ellipsis) and the unicode spaces a paste can carry. The model echoes a
|
||||
// suggestion's `original` from the same text but often normalizes these back to
|
||||
// plain ASCII — straight quotes, `--`, `...` — so an exact match would miss and
|
||||
// the suggestion could be neither highlighted nor applied.
|
||||
//
|
||||
// It returns the folded string plus `map`, where map[k] is the source index at
|
||||
// which folded character k begins. A multi-character source run (`...`, `--`)
|
||||
// folds to one glyph, so lengths differ; map projects a match in folded space
|
||||
// back onto real document offsets. map has a trailing sentinel (= source length)
|
||||
// so map[start + folded.length] yields the offset just past the match.
|
||||
export function foldTypography(text: string): { folded: string; map: number[] } {
|
||||
let folded = ''
|
||||
const map: number[] = []
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
// Multi-character ASCII sequences the Typography rules would have replaced.
|
||||
if (text.startsWith('...', i)) {
|
||||
folded += '…'
|
||||
map.push(i)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if (text[i] === '-' && text[i + 1] === '-') {
|
||||
folded += '—'
|
||||
map.push(i)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
folded += foldChar(text[i])
|
||||
map.push(i)
|
||||
i++
|
||||
}
|
||||
map.push(text.length)
|
||||
return { folded, map }
|
||||
}
|
||||
|
||||
// foldChar maps a single character onto its canonical form (length-preserving).
|
||||
function foldChar(c: string): string {
|
||||
switch (c) {
|
||||
case '‘':
|
||||
case '’':
|
||||
case '‚':
|
||||
case '‛':
|
||||
return "'"
|
||||
case '“':
|
||||
case '”':
|
||||
case '„':
|
||||
case '‟':
|
||||
return '"'
|
||||
case '–': // en dash → em dash, the canonical form `--` also folds to
|
||||
return '—'
|
||||
case ' ': // non-breaking space
|
||||
case ' ': // thin space
|
||||
case ' ': // narrow no-break space
|
||||
return ' '
|
||||
default:
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
// findRange locates the first occurrence of `search` within a single textblock
|
||||
// and returns its ProseMirror range, or null if the string isn't present (the
|
||||
// user may have edited or removed it since the checkpoint ran). Exported so the
|
||||
// accept flow can resolve the same span to replace.
|
||||
// user may have edited or removed it since the checkpoint ran). Matching is done
|
||||
// in canonical typographic space (see foldTypography) so curly-quote/dash/ellipsis
|
||||
// differences between the model's echo and the live document don't strand the
|
||||
// anchor. Exported so the accept flow can resolve the same span to replace.
|
||||
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
|
||||
if (!search) return null
|
||||
const needle = foldTypography(search).folded
|
||||
if (!needle) return null
|
||||
let result: { from: number; to: number } | null = null
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false
|
||||
if (!node.isTextblock) return true // keep descending to the textblock
|
||||
const idx = node.textContent.indexOf(search)
|
||||
const { folded, map } = foldTypography(node.textContent)
|
||||
const idx = folded.indexOf(needle)
|
||||
if (idx >= 0) {
|
||||
result = {
|
||||
from: mapOffset(node, pos, idx),
|
||||
to: mapOffset(node, pos, idx + search.length),
|
||||
from: mapOffset(node, pos, map[idx]),
|
||||
to: mapOffset(node, pos, map[idx + needle.length]),
|
||||
}
|
||||
}
|
||||
return false // never descend into a textblock's inline children
|
||||
@@ -64,14 +138,15 @@ export function findRange(doc: PMNode, search: string): { from: number; to: numb
|
||||
return result
|
||||
}
|
||||
|
||||
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet {
|
||||
function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
|
||||
const decos: Decoration[] = []
|
||||
for (const s of suggestions) {
|
||||
const range = findRange(doc, s.original)
|
||||
if (!range) continue
|
||||
const active = s.id === activeId ? ' petal-suggestion-active' : ''
|
||||
decos.push(
|
||||
Decoration.inline(range.from, range.to, {
|
||||
class: `petal-suggestion petal-suggestion-${s.type}`,
|
||||
class: `petal-suggestion petal-suggestion-${s.type}${active}`,
|
||||
'data-suggestion-id': s.id,
|
||||
}),
|
||||
)
|
||||
@@ -82,10 +157,22 @@ function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet
|
||||
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
||||
// rebuilt against the current document immediately.
|
||||
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
||||
const tr = state.tr.setMeta(suggestionPluginKey, suggestions)
|
||||
const tr = state.tr.setMeta(suggestionPluginKey, { suggestions } satisfies SuggestionMeta)
|
||||
dispatch(tr)
|
||||
}
|
||||
|
||||
// setActiveSuggestion marks one suggestion (or none) as emphasized, rebuilding
|
||||
// the decorations so the flagged text gets the active wash. Driven by the margin
|
||||
// rail's hover/selection so the card↔text link reads both ways.
|
||||
export function setActiveSuggestion(
|
||||
state: EditorState,
|
||||
dispatch: (tr: Transaction) => void,
|
||||
activeId: string | null,
|
||||
) {
|
||||
if (suggestionPluginKey.getState(state)?.activeId === activeId) return
|
||||
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
|
||||
}
|
||||
|
||||
export const SuggestionHighlight = Extension.create({
|
||||
name: 'suggestionHighlight',
|
||||
|
||||
@@ -94,17 +181,29 @@ export const SuggestionHighlight = Extension.create({
|
||||
new Plugin<PluginState>({
|
||||
key: suggestionPluginKey,
|
||||
state: {
|
||||
init: () => ({ suggestions: [], decorations: DecorationSet.empty }),
|
||||
init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
|
||||
apply(tr, value, _oldState, newState) {
|
||||
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined
|
||||
if (meta) {
|
||||
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) }
|
||||
const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
|
||||
if (meta && 'suggestions' in meta) {
|
||||
return {
|
||||
suggestions: meta.suggestions,
|
||||
activeId: value.activeId,
|
||||
decorations: buildDecorations(newState.doc, meta.suggestions, value.activeId),
|
||||
}
|
||||
}
|
||||
if (meta && 'activeId' in meta) {
|
||||
return {
|
||||
suggestions: value.suggestions,
|
||||
activeId: meta.activeId,
|
||||
decorations: buildDecorations(newState.doc, value.suggestions, meta.activeId),
|
||||
}
|
||||
}
|
||||
// On any document change, re-anchor by string against the new doc.
|
||||
if (tr.docChanged) {
|
||||
return {
|
||||
suggestions: value.suggestions,
|
||||
decorations: buildDecorations(newState.doc, value.suggestions),
|
||||
activeId: value.activeId,
|
||||
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
|
||||
}
|
||||
}
|
||||
return value
|
||||
|
||||
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
import { AskPetal } from './AskPetal'
|
||||
import { TYPE_META } from './suggestionMeta'
|
||||
|
||||
// Vertical breathing room kept between stacked cards when their natural anchors
|
||||
// would otherwise collide.
|
||||
const CARD_GAP = 12
|
||||
|
||||
export interface RailItem {
|
||||
suggestion: Suggestion
|
||||
// Vertical offset (px) of the suggestion's highlight, relative to the editor
|
||||
// wrapper — the position the card wants to sit beside.
|
||||
anchorTop: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: RailItem[]
|
||||
// The suggestion currently emphasized (its highlight hovered, or this card
|
||||
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
|
||||
activeId: string | null
|
||||
// The card expanded to show the full explanation + Ask Petal, or null.
|
||||
expandedId: string | null
|
||||
onAccept: (s: Suggestion) => void
|
||||
onDismiss: (s: Suggestion) => void
|
||||
// Pointer entering/leaving a card, so the matching highlight can light up.
|
||||
onHover: (id: string | null) => void
|
||||
// A card's body was clicked — scroll its highlight into view and toggle expand.
|
||||
onActivate: (id: string) => void
|
||||
onToggleExpand: (id: string) => void
|
||||
}
|
||||
|
||||
// SuggestionRail is the right-margin "comment column": every outstanding
|
||||
// suggestion as a card, vertically aligned to the text it flags, so the writer
|
||||
// sees the whole queue at once instead of hunting highlight by highlight. Cards
|
||||
// are anchored to their highlight's vertical position and pushed down only as far
|
||||
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
|
||||
export function SuggestionRail({
|
||||
items,
|
||||
activeId,
|
||||
expandedId,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onHover,
|
||||
onActivate,
|
||||
onToggleExpand,
|
||||
}: Props) {
|
||||
// Measured resolved tops keyed by suggestion id (after collision avoidance).
|
||||
const [tops, setTops] = useState<Record<string, number>>({})
|
||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
|
||||
// text) so the stack re-packs and cards below don't get overlapped.
|
||||
const [measureTick, setMeasureTick] = useState(0)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
|
||||
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
|
||||
}
|
||||
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
|
||||
|
||||
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
|
||||
// would overlap the previous card, in which case it slides down just enough.
|
||||
// Re-runs whenever the anchors or the expanded card (which changes a height)
|
||||
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
|
||||
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
|
||||
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
|
||||
useLayoutEffect(() => {
|
||||
let cursor = -Infinity
|
||||
const next: Record<string, number> = {}
|
||||
for (const { suggestion, anchorTop } of ordered) {
|
||||
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
|
||||
const top = Math.max(anchorTop, cursor)
|
||||
next[suggestion.id] = top
|
||||
cursor = top + h + CARD_GAP
|
||||
}
|
||||
setTops((prev) => {
|
||||
const ids = Object.keys(next)
|
||||
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
|
||||
return next
|
||||
})
|
||||
// layoutKey captures anchor positions; expandedId/measureTick capture height
|
||||
// changes (toggling detail, Ask Petal streaming in).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layoutKey, expandedId, measureTick])
|
||||
|
||||
return (
|
||||
<div className="petal-rail petal-no-print" aria-label="Suggestions">
|
||||
{ordered.map(({ suggestion }) => (
|
||||
<RailCard
|
||||
key={suggestion.id}
|
||||
ref={(el) => {
|
||||
const prev = cardRefs.current.get(suggestion.id)
|
||||
if (prev && prev !== el) observerRef.current?.unobserve(prev)
|
||||
if (el) {
|
||||
cardRefs.current.set(suggestion.id, el)
|
||||
observerRef.current?.observe(el)
|
||||
} else {
|
||||
cardRefs.current.delete(suggestion.id)
|
||||
}
|
||||
}}
|
||||
suggestion={suggestion}
|
||||
top={tops[suggestion.id] ?? 0}
|
||||
active={activeId === suggestion.id}
|
||||
expanded={expandedId === suggestion.id}
|
||||
onAccept={onAccept}
|
||||
onDismiss={onDismiss}
|
||||
onHover={onHover}
|
||||
onActivate={onActivate}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
suggestion: Suggestion
|
||||
top: number
|
||||
active: boolean
|
||||
expanded: boolean
|
||||
onAccept: (s: Suggestion) => void
|
||||
onDismiss: (s: Suggestion) => void
|
||||
onHover: (id: string | null) => void
|
||||
onActivate: (id: string) => void
|
||||
onToggleExpand: (id: string) => void
|
||||
}
|
||||
|
||||
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
|
||||
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
|
||||
ref,
|
||||
) {
|
||||
const meta = TYPE_META[suggestion.type]
|
||||
const hasReplacement = suggestion.replacement.trim() !== ''
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-label={`${meta.label} suggestion`}
|
||||
onMouseEnter={() => onHover(suggestion.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
|
||||
style={{ top, borderLeftColor: meta.color }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
|
||||
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss suggestion"
|
||||
onClick={() => onDismiss(suggestion)}
|
||||
className="petal-rail-x"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The body toggles the expanded explanation + Ask Petal and points the
|
||||
editor at the flagged text. */}
|
||||
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
|
||||
{hasReplacement && (
|
||||
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
||||
{suggestion.original}
|
||||
</span>
|
||||
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
||||
{suggestion.replacement}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
>
|
||||
{suggestion.explanation}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-2">
|
||||
{hasReplacement && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAccept(suggestion)}
|
||||
className="rounded-full px-3 py-1 text-xs font-bold text-white"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleExpand(suggestion.id)}
|
||||
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||
style={{
|
||||
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
|
||||
color: 'var(--color-accent-hover)',
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
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 { 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
|
||||
|
||||
12
web/src/components/Editor/suggestionMeta.ts
Normal file
12
web/src/components/Editor/suggestionMeta.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { SuggestionType } from '../../api/client'
|
||||
|
||||
// Per-type accent color + human label, mirroring the design tokens. Shared by the
|
||||
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion
|
||||
// type reads the same color/name wherever it surfaces.
|
||||
export const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
||||
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
||||
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSoundEnabled, onSoundEnabledChange, playSound, setSoundEnabled } from '../../audio/sounds'
|
||||
import { isSoundEnabled, onSoundEnabledChange, playPop, setSoundEnabled } from '../../audio/sounds'
|
||||
|
||||
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
|
||||
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
|
||||
@@ -14,7 +14,7 @@ export function SoundToggle() {
|
||||
const next = !on
|
||||
setSoundEnabled(next)
|
||||
setOn(next)
|
||||
if (next) playSound('pop') // a little hello when re-enabled
|
||||
if (next) playPop() // a little hello when re-enabled
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -24,7 +24,7 @@ export function SoundToggle() {
|
||||
aria-pressed={on}
|
||||
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
|
||||
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
|
||||
className="rounded-full px-1.5 py-0.5 transition-colors"
|
||||
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
||||
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) =>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
|
||||
className="flex h-11 shrink-0 items-center gap-3 px-6 text-sm"
|
||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
<div className="relative" ref={statsRef}>
|
||||
@@ -54,7 +54,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
|
||||
onClick={() => setStatsOpen((o) => !o)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={statsOpen}
|
||||
className="rounded-full px-1.5 py-0.5 font-semibold transition-colors"
|
||||
className="rounded-full px-2 py-0.5 text-[0.95rem] font-bold transition-colors"
|
||||
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) =>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { useEditorState } from '@tiptap/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { uploadImageInto } from '../Editor/EditorCore'
|
||||
|
||||
interface Props {
|
||||
editor: Editor | null
|
||||
@@ -15,18 +17,21 @@ function TBtn({
|
||||
active,
|
||||
disabled,
|
||||
label,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={title ?? label}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onMouseDown={(e) => e.preventDefault()} // keep editor selection
|
||||
@@ -47,10 +52,125 @@ const Divider = () => (
|
||||
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
|
||||
)
|
||||
|
||||
// A popover anchored under its trigger. The trigger + panel share a relative
|
||||
// wrapper; `open`/`onClose` are owned by the toolbar so only one is open at once.
|
||||
// A pointer-down outside the wrapper closes it.
|
||||
function Popover({
|
||||
open,
|
||||
onClose,
|
||||
trigger,
|
||||
children,
|
||||
width = 200,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
trigger: React.ReactNode
|
||||
children: React.ReactNode
|
||||
width?: number
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open, onClose])
|
||||
return (
|
||||
<div ref={ref} className="relative flex items-center">
|
||||
{trigger}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 top-full z-40 mt-1.5 p-2"
|
||||
style={{
|
||||
width,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Text-color swatches. `null` clears the color back to the theme default.
|
||||
const TEXT_COLORS: { label: string; value: string | null }[] = [
|
||||
{ label: 'Default', value: null },
|
||||
{ label: 'Rose', value: '#D98AAF' },
|
||||
{ label: 'Coral', value: '#E0785C' },
|
||||
{ label: 'Honey', value: '#C68B2E' },
|
||||
{ label: 'Green', value: '#4F9E7F' },
|
||||
{ label: 'Sky', value: '#4E8FCC' },
|
||||
{ label: 'Lavender', value: '#7D63C4' },
|
||||
{ label: 'Plum', value: '#3D2E39' },
|
||||
]
|
||||
|
||||
// Highlighter colors — soft pastels so dark text stays readable on top.
|
||||
const HIGHLIGHTS: { label: string; value: string | null }[] = [
|
||||
{ label: 'None', value: null },
|
||||
{ label: 'Yellow', value: '#FFF1A8' },
|
||||
{ label: 'Pink', value: '#FAD4E4' },
|
||||
{ label: 'Mint', value: '#CDEFE2' },
|
||||
{ label: 'Peach', value: '#FBE0CF' },
|
||||
{ label: 'Lavender', value: '#E6DCFA' },
|
||||
{ label: 'Sky', value: '#D6E8FB' },
|
||||
]
|
||||
|
||||
// Font-size presets. `null` clears back to the document default.
|
||||
const SIZES: { label: string; value: string | null; em: string }[] = [
|
||||
{ label: 'Small', value: '0.85em', em: '0.85em' },
|
||||
{ label: 'Normal', value: null, em: '1em' },
|
||||
{ label: 'Large', value: '1.3em', em: '1.3em' },
|
||||
{ label: 'Title', value: '1.7em', em: '1.7em' },
|
||||
]
|
||||
|
||||
// A round color chip used in the color/highlight palettes.
|
||||
function Swatch({
|
||||
color,
|
||||
active,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
color: string | null
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className="flex h-7 w-7 items-center justify-center"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: color ?? 'var(--color-surface)',
|
||||
border: active ? '2px solid var(--color-accent)' : '1px solid var(--color-border)',
|
||||
// The "clear" chip (no color) shows a tiny diagonal stroke.
|
||||
backgroundImage: color
|
||||
? undefined
|
||||
: 'linear-gradient(135deg, transparent 44%, var(--color-accent) 44%, var(--color-accent) 56%, transparent 56%)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
|
||||
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
||||
// 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' | 'outline' | null>(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) =>
|
||||
@@ -59,27 +179,92 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
bold: editor.isActive('bold'),
|
||||
italic: editor.isActive('italic'),
|
||||
underline: editor.isActive('underline'),
|
||||
strike: editor.isActive('strike'),
|
||||
h1: editor.isActive('heading', { level: 1 }),
|
||||
h2: editor.isActive('heading', { level: 2 }),
|
||||
h3: editor.isActive('heading', { level: 3 }),
|
||||
bullet: editor.isActive('bulletList'),
|
||||
ordered: editor.isActive('orderedList'),
|
||||
left: editor.isActive({ textAlign: 'left' }),
|
||||
center: editor.isActive({ textAlign: 'center' }),
|
||||
right: editor.isActive({ textAlign: 'right' }),
|
||||
link: editor.isActive('link'),
|
||||
inTable: editor.isActive('table'),
|
||||
color: editor.getAttributes('textStyle').color ?? null,
|
||||
highlight: editor.getAttributes('highlight').color ?? null,
|
||||
fontSize: editor.getAttributes('textStyle').fontSize ?? null,
|
||||
canUndo: editor.can().undo(),
|
||||
canRedo: editor.can().redo(),
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
if (!editor || !state) return null
|
||||
|
||||
const close = () => setMenu(null)
|
||||
|
||||
// Open the link popover, pre-filling the field with any link already on the
|
||||
// selection so it can be edited rather than retyped.
|
||||
const openLink = () => {
|
||||
setLinkUrl(editor.getAttributes('link').href ?? '')
|
||||
setMenu(menu === 'link' ? null : 'link')
|
||||
}
|
||||
|
||||
const applyLink = () => {
|
||||
const url = linkUrl.trim()
|
||||
if (!url) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
} else {
|
||||
// Default to https:// when the writer omits a scheme.
|
||||
const href = /^(https?:|mailto:|\/)/i.test(url) ? url : `https://${url}`
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
||||
}
|
||||
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)
|
||||
e.target.value = '' // allow re-picking the same file
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
|
||||
className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
<TBtn label="Undo" disabled={!state.canUndo} onClick={() => editor.chain().focus().undo().run()}>
|
||||
↶
|
||||
</TBtn>
|
||||
<TBtn label="Redo" disabled={!state.canRedo} onClick={() => editor.chain().focus().redo().run()}>
|
||||
↷
|
||||
</TBtn>
|
||||
<Divider />
|
||||
|
||||
<TBtn label="Bold" active={state.bold} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<span className="font-extrabold">B</span>
|
||||
</TBtn>
|
||||
@@ -93,51 +278,301 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
>
|
||||
<span className="underline">U</span>
|
||||
</TBtn>
|
||||
<TBtn label="Strikethrough" active={state.strike} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<span className="line-through">S</span>
|
||||
</TBtn>
|
||||
<Divider />
|
||||
<TBtn
|
||||
label="Heading 1"
|
||||
active={state.h1}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
|
||||
{/* Text color */}
|
||||
<Popover
|
||||
open={menu === 'color'}
|
||||
onClose={close}
|
||||
width={188}
|
||||
trigger={
|
||||
<TBtn label="Text color" active={menu === 'color'} onClick={() => setMenu(menu === 'color' ? null : 'color')}>
|
||||
<span className="flex flex-col items-center leading-none">
|
||||
<span className="text-sm font-bold">A</span>
|
||||
<span
|
||||
className="mt-0.5 h-1 w-4 rounded-full"
|
||||
style={{ background: state.color ?? 'var(--color-accent)' }}
|
||||
/>
|
||||
</span>
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{TEXT_COLORS.map((c) => (
|
||||
<Swatch
|
||||
key={c.label}
|
||||
color={c.value}
|
||||
title={c.label}
|
||||
active={state.color === c.value}
|
||||
onClick={() => {
|
||||
if (c.value) editor.chain().focus().setColor(c.value).run()
|
||||
else editor.chain().focus().unsetColor().run()
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
{/* Highlight */}
|
||||
<Popover
|
||||
open={menu === 'highlight'}
|
||||
onClose={close}
|
||||
width={170}
|
||||
trigger={
|
||||
<TBtn
|
||||
label="Highlight"
|
||||
active={menu === 'highlight' || !!state.highlight}
|
||||
onClick={() => setMenu(menu === 'highlight' ? null : 'highlight')}
|
||||
>
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-xs font-bold"
|
||||
style={{ background: state.highlight ?? '#FFF1A8', color: 'var(--color-plum)' }}
|
||||
>
|
||||
H
|
||||
</span>
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{HIGHLIGHTS.map((c) => (
|
||||
<Swatch
|
||||
key={c.label}
|
||||
color={c.value}
|
||||
title={c.label}
|
||||
active={state.highlight === c.value}
|
||||
onClick={() => {
|
||||
if (c.value) editor.chain().focus().setHighlight({ color: c.value }).run()
|
||||
else editor.chain().focus().unsetHighlight().run()
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
{/* Font size */}
|
||||
<Popover
|
||||
open={menu === 'size'}
|
||||
onClose={close}
|
||||
width={140}
|
||||
trigger={
|
||||
<TBtn label="Text size" active={menu === 'size'} onClick={() => setMenu(menu === 'size' ? null : 'size')}>
|
||||
<span className="text-xs font-bold tracking-tight">
|
||||
<span className="text-[0.7rem]">A</span>
|
||||
<span className="text-sm">A</span>
|
||||
</span>
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{SIZES.map((s) => {
|
||||
const active = state.fontSize === s.value
|
||||
return (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
if (s.value) editor.chain().focus().setFontSize(s.value).run()
|
||||
else editor.chain().focus().unsetFontSize().run()
|
||||
close()
|
||||
}}
|
||||
className="flex items-center justify-between rounded px-2 py-1 text-left"
|
||||
style={{
|
||||
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||
color: active ? 'var(--color-accent-hover)' : 'var(--color-plum)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: s.em }}>{s.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Popover>
|
||||
<Divider />
|
||||
|
||||
<TBtn label="Heading 1" active={state.h1} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
|
||||
H1
|
||||
</TBtn>
|
||||
<TBtn
|
||||
label="Heading 2"
|
||||
active={state.h2}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<TBtn label="Heading 2" active={state.h2} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||
H2
|
||||
</TBtn>
|
||||
<TBtn
|
||||
label="Bullet list"
|
||||
active={state.bullet}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<TBtn label="Heading 3" active={state.h3} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||
H3
|
||||
</TBtn>
|
||||
<TBtn label="Bullet list" active={state.bullet} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
•
|
||||
</TBtn>
|
||||
<TBtn label="Numbered list" active={state.ordered} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
1.
|
||||
</TBtn>
|
||||
<Divider />
|
||||
<TBtn
|
||||
label="Align left"
|
||||
active={state.left}
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
>
|
||||
|
||||
<TBtn label="Align left" active={state.left} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
|
||||
⇤
|
||||
</TBtn>
|
||||
<TBtn
|
||||
label="Align center"
|
||||
active={state.center}
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
>
|
||||
<TBtn label="Align center" active={state.center} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
|
||||
↔
|
||||
</TBtn>
|
||||
<TBtn
|
||||
label="Align right"
|
||||
active={state.right}
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
>
|
||||
<TBtn label="Align right" active={state.right} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
|
||||
⇥
|
||||
</TBtn>
|
||||
<Divider />
|
||||
|
||||
{/* Link */}
|
||||
<Popover
|
||||
open={menu === 'link'}
|
||||
onClose={close}
|
||||
width={236}
|
||||
trigger={
|
||||
<TBtn label="Link" active={state.link || menu === 'link'} onClick={openLink}>
|
||||
🔗
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') applyLink()
|
||||
if (e.key === 'Escape') close()
|
||||
}}
|
||||
placeholder="https://…"
|
||||
className="w-full px-2 py-1.5 text-sm focus:outline-none"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
{state.link ? (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
close()
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-semibold"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={applyLink}
|
||||
className="px-3 py-1 text-xs font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-accent)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
{/* Image */}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onPickImage} />
|
||||
<TBtn label="Insert image" onClick={() => fileInputRef.current?.click()}>
|
||||
🖼️
|
||||
</TBtn>
|
||||
|
||||
{/* Table */}
|
||||
<Popover
|
||||
open={menu === 'table'}
|
||||
onClose={close}
|
||||
width={state.inTable ? 188 : 150}
|
||||
trigger={
|
||||
<TBtn label="Table" active={state.inTable || menu === 'table'} onClick={() => setMenu(menu === 'table' ? null : 'table')}>
|
||||
▦
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
{state.inTable ? (
|
||||
<div className="flex flex-col gap-0.5 text-sm" style={{ color: 'var(--color-plum)' }}>
|
||||
<TableAction label="Add row below" onClick={() => editor.chain().focus().addRowAfter().run()} />
|
||||
<TableAction label="Add column right" onClick={() => editor.chain().focus().addColumnAfter().run()} />
|
||||
<TableAction label="Toggle header row" onClick={() => editor.chain().focus().toggleHeaderRow().run()} />
|
||||
<TableAction label="Delete row" onClick={() => editor.chain().focus().deleteRow().run()} />
|
||||
<TableAction label="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()} />
|
||||
<TableAction
|
||||
label="Delete table"
|
||||
danger
|
||||
onClick={() => {
|
||||
editor.chain().focus().deleteTable().run()
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<GridPicker
|
||||
onPick={(rows, cols) => {
|
||||
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run()
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
aria-label="Check my voice"
|
||||
@@ -162,3 +597,58 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A row in the in-table editing menu.
|
||||
function TableAction({ label, onClick, danger }: { label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className="rounded px-2 py-1 text-left"
|
||||
style={{ color: danger ? 'var(--color-accent-hover)' : 'var(--color-plum)' }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// A hover-to-size grid for inserting a table. Hovering a cell highlights the
|
||||
// rectangle from the top-left; clicking inserts that many rows × columns.
|
||||
function GridPicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||
const MAX = 6
|
||||
const [hover, setHover] = useState<{ r: number; c: number }>({ r: 0, c: 0 })
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="grid gap-0.5"
|
||||
style={{ gridTemplateColumns: `repeat(${MAX}, 1fr)` }}
|
||||
onMouseLeave={() => setHover({ r: 0, c: 0 })}
|
||||
>
|
||||
{Array.from({ length: MAX * MAX }).map((_, i) => {
|
||||
const r = Math.floor(i / MAX) + 1
|
||||
const c = (i % MAX) + 1
|
||||
const on = r <= hover.r && c <= hover.c
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHover({ r, c })}
|
||||
onClick={() => onPick(r, c)}
|
||||
className="h-4 w-4"
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
background: on ? 'var(--color-accent)' : 'var(--color-surface-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1.5 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{hover.r > 0 ? `${hover.r} × ${hover.c}` : 'Pick a size'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,121 +1,216 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a
|
||||
// handful of soft flower petals down the page. It sits behind everything,
|
||||
// ignores pointer events, and stays sparse so it reads as "occasional petals on
|
||||
// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering
|
||||
// nothing. No external libraries — a single tiny shader program animates every
|
||||
// petal entirely on the GPU, so the CPU just spins a clock.
|
||||
// PetalFall is the cozy ambient layer: a fixed, non-interactive 2D canvas that
|
||||
// drifts soft cherry-blossom petals down the page. Each petal is a real
|
||||
// petal-shaped sprite (notched sakura silhouette with a gentle gradient and
|
||||
// soft alpha), pre-rendered once per color and then blitted many times with
|
||||
// rotation + sway. It sits behind the chrome, ignores pointer events, pauses
|
||||
// when the tab is hidden, and renders nothing for prefers-reduced-motion.
|
||||
|
||||
// Each petal is a quad whose corners we expand in the vertex shader. We pack one
|
||||
// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus
|
||||
// the petal's static randomness (lane, speed, size, sway, tint, spin).
|
||||
const PETAL_COUNT = 26
|
||||
|
||||
// Soft Petal-palette tints (rose / blush / lavender), as RGB 0..1.
|
||||
const TINTS: [number, number, number][] = [
|
||||
[0.91, 0.63, 0.75], // accent rose #E8A0BF
|
||||
[1.0, 0.94, 0.96], // near-white blush
|
||||
[0.96, 0.72, 0.63], // peach #F4B8A0
|
||||
[0.77, 0.71, 0.91], // lavender #C5B4E8
|
||||
[0.85, 0.55, 0.69], // deeper rose
|
||||
// Saturated-but-soft blossom colors. Each petal fades from a light, near-white
|
||||
// tip to its color at the base, so it reads as a real petal rather than a blob.
|
||||
const PALETTE: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 150, b: 190 }, // rose pink
|
||||
{ r: 255, g: 184, b: 205 }, // pale blossom
|
||||
{ r: 255, g: 158, b: 130 }, // warm peach
|
||||
{ r: 209, g: 160, b: 255 }, // lavender
|
||||
{ r: 255, g: 122, b: 172 }, // deep rose
|
||||
]
|
||||
|
||||
const VERT = `
|
||||
precision mediump float;
|
||||
attribute vec2 a_corner; // quad corner in [-0.5,0.5]
|
||||
attribute vec4 a_rand; // x: lane(0..1), y: speed, z: size, w: phase
|
||||
attribute vec4 a_rand2; // x: swayAmp, y: swayFreq, z: spin, w: tintIdx
|
||||
uniform float u_time;
|
||||
uniform vec2 u_res;
|
||||
varying vec2 v_uv;
|
||||
varying float v_tint;
|
||||
varying float v_alpha;
|
||||
// Night-mode sparkle palette: soft starlight tones for the small twinkle accents
|
||||
// scattered between the chunky cartoon stars (moonlit white, warm gold).
|
||||
const STAR_PALETTE: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 255, b: 255 }, // white
|
||||
{ r: 255, g: 240, b: 200 }, // warm gold
|
||||
]
|
||||
|
||||
void main() {
|
||||
v_uv = a_corner + 0.5;
|
||||
v_tint = a_rand2.w;
|
||||
// Chunky cartoon-star colors — bold, candy-bright fills à la a Mario power star
|
||||
// or a Kirby warp star. Each is drawn with a glossy gradient + a puffy outline.
|
||||
const CARTOON_COLORS: { r: number; g: number; b: number }[] = [
|
||||
{ r: 255, g: 201, b: 56 }, // golden power-star
|
||||
{ r: 255, g: 150, b: 190 }, // bubblegum pink
|
||||
{ r: 130, g: 195, b: 255 }, // sky blue
|
||||
{ r: 150, g: 225, b: 190 }, // mint
|
||||
{ r: 200, g: 175, b: 255 }, // lavender
|
||||
]
|
||||
|
||||
float lane = a_rand.x;
|
||||
float speed = a_rand.y;
|
||||
float size = a_rand.z;
|
||||
float phase = a_rand.w;
|
||||
const SPRITE_PX = 160 // off-screen render resolution per petal sprite
|
||||
|
||||
// Vertical fall: loop 0..1 over time, offset per petal so they don't sync.
|
||||
float fall = fract(u_time * speed + phase);
|
||||
// Map to clip space: start above the top (1.15) fall to below bottom (-1.15).
|
||||
float y = 1.15 - fall * 2.3;
|
||||
|
||||
// Horizontal: a base lane plus a gentle sway as it descends.
|
||||
float sway = a_rand2.x * sin(u_time * a_rand2.y + phase * 6.2831);
|
||||
float x = (lane * 2.0 - 1.0) + sway;
|
||||
|
||||
// Spin the quad over time.
|
||||
float ang = u_time * a_rand2.z + phase * 6.2831;
|
||||
float c = cos(ang), s = sin(ang);
|
||||
vec2 corner = a_corner * size;
|
||||
// Keep petals visually square despite aspect ratio.
|
||||
corner.x *= u_res.y / u_res.x;
|
||||
vec2 rot = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
|
||||
|
||||
// Fade in near the top and out near the bottom so they don't pop.
|
||||
v_alpha = smoothstep(0.0, 0.12, fall) * (1.0 - smoothstep(0.86, 1.0, fall));
|
||||
|
||||
gl_Position = vec4(x + rot.x, y + rot.y, 0.0, 1.0);
|
||||
function rgba(c: { r: number; g: number; b: number }, a: number): string {
|
||||
return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${a})`
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `
|
||||
precision mediump float;
|
||||
varying vec2 v_uv;
|
||||
varying float v_tint;
|
||||
varying float v_alpha;
|
||||
uniform vec3 u_tints[5];
|
||||
|
||||
// A soft petal/teardrop alpha mask centered in the quad.
|
||||
float petalMask(vec2 uv) {
|
||||
vec2 p = uv - vec2(0.5);
|
||||
// Teardrop: pinch the top, round the bottom.
|
||||
float r = length(vec2(p.x * 1.65, p.y * 1.15 + 0.12));
|
||||
float body = smoothstep(0.5, 0.16, r);
|
||||
// A subtle crease down the middle for a petal feel.
|
||||
float crease = 1.0 - 0.18 * exp(-pow(p.x * 7.0, 2.0));
|
||||
return body * crease;
|
||||
function mixWhite(c: { r: number; g: number; b: number }, t: number) {
|
||||
return { r: c.r + (255 - c.r) * t, g: c.g + (255 - c.g) * t, b: c.b + (255 - c.b) * t }
|
||||
}
|
||||
function scale(c: { r: number; g: number; b: number }, t: number) {
|
||||
return { r: c.r * t, g: c.g * t, b: c.b * t }
|
||||
}
|
||||
|
||||
void main() {
|
||||
float m = petalMask(v_uv);
|
||||
if (m < 0.01) discard;
|
||||
int idx = int(v_tint + 0.5);
|
||||
vec3 col = u_tints[0];
|
||||
if (idx == 1) col = u_tints[1];
|
||||
else if (idx == 2) col = u_tints[2];
|
||||
else if (idx == 3) col = u_tints[3];
|
||||
else if (idx == 4) col = u_tints[4];
|
||||
gl_FragColor = vec4(col, m * v_alpha * 0.55);
|
||||
}
|
||||
`
|
||||
// Draw one petal sprite: a notched sakura silhouette filled with a tip→base
|
||||
// gradient and a faint central highlight, on a transparent canvas.
|
||||
function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
|
||||
const s = SPRITE_PX
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = s
|
||||
cv.height = s
|
||||
const g = cv.getContext('2d')!
|
||||
g.translate(s / 2, s / 2)
|
||||
|
||||
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
|
||||
const sh = gl.createShader(type)
|
||||
if (!sh) return null
|
||||
gl.shaderSource(sh, src)
|
||||
gl.compileShader(sh)
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
console.error('petal shader compile failed', gl.getShaderInfoLog(sh))
|
||||
gl.deleteShader(sh)
|
||||
return null
|
||||
const hw = s * 0.3 // half width
|
||||
const hh = s * 0.44 // half height
|
||||
|
||||
g.beginPath()
|
||||
g.moveTo(0, hh) // pointed base
|
||||
g.bezierCurveTo(hw, hh * 0.42, hw * 0.92, -hh * 0.52, hw * 0.2, -hh * 0.92) // right side → near tip
|
||||
g.quadraticCurveTo(0, -hh * 0.72, -hw * 0.2, -hh * 0.92) // the sakura notch
|
||||
g.bezierCurveTo(-hw * 0.92, -hh * 0.52, -hw, hh * 0.42, 0, hh) // left side → base
|
||||
g.closePath()
|
||||
|
||||
const grad = g.createLinearGradient(0, -hh, 0, hh)
|
||||
grad.addColorStop(0, rgba(mixWhite(color, 0.55), 0.78)) // light, soft tip
|
||||
grad.addColorStop(0.45, rgba(color, 0.88))
|
||||
grad.addColorStop(1, rgba(scale(color, 0.8), 0.95)) // deeper base
|
||||
g.fillStyle = grad
|
||||
g.fill()
|
||||
|
||||
// A soft white sheen down the centre for a delicate, glossy feel.
|
||||
const sheen = g.createLinearGradient(-hw * 0.3, 0, hw * 0.3, 0)
|
||||
sheen.addColorStop(0, 'rgba(255,255,255,0)')
|
||||
sheen.addColorStop(0.5, 'rgba(255,255,255,0.28)')
|
||||
sheen.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
g.fillStyle = sheen
|
||||
g.fill()
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
// Draw one star sprite: a soft radial glow with a crisp four-point sparkle on
|
||||
// top, filled brightest at the core so it reads as a twinkling star.
|
||||
function makeStarSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
|
||||
const s = SPRITE_PX
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = s
|
||||
cv.height = s
|
||||
const g = cv.getContext('2d')!
|
||||
g.translate(s / 2, s / 2)
|
||||
|
||||
// Soft halo behind the sparkle.
|
||||
const glow = g.createRadialGradient(0, 0, 0, 0, 0, s * 0.5)
|
||||
glow.addColorStop(0, rgba(mixWhite(color, 0.5), 0.5))
|
||||
glow.addColorStop(0.35, rgba(color, 0.16))
|
||||
glow.addColorStop(1, rgba(color, 0))
|
||||
g.fillStyle = glow
|
||||
g.beginPath()
|
||||
g.arc(0, 0, s * 0.5, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
// Four-point sparkle: alternate a long outer radius with a small inner one so
|
||||
// the points pinch in sharply.
|
||||
const R = s * 0.46
|
||||
const r = s * 0.08
|
||||
g.beginPath()
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const ang = (i * Math.PI) / 4 - Math.PI / 2
|
||||
const rad = i % 2 === 0 ? R : r
|
||||
const x = Math.cos(ang) * rad
|
||||
const y = Math.sin(ang) * rad
|
||||
if (i === 0) g.moveTo(x, y)
|
||||
else g.lineTo(x, y)
|
||||
}
|
||||
return sh
|
||||
g.closePath()
|
||||
const core = g.createRadialGradient(0, 0, 0, 0, 0, R)
|
||||
core.addColorStop(0, rgba(mixWhite(color, 0.7), 0.98))
|
||||
core.addColorStop(0.5, rgba(color, 0.9))
|
||||
core.addColorStop(1, rgba(color, 0.2))
|
||||
g.fillStyle = core
|
||||
g.fill()
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
// Deterministic-ish pseudo random (we can't use Math.random at module scope for
|
||||
// SSR safety, but here in an effect it's fine; kept simple).
|
||||
function rand(): number {
|
||||
return Math.random()
|
||||
// Draw one chunky cartoon star — a fat five-point star with a glossy radial
|
||||
// fill, a soft outer glow so it pops off the dark sky, a puffy rounded outline
|
||||
// (round line joins → Kirby-ish soft points), and a little corner shine. Reads
|
||||
// like a game power-up rather than a delicate sparkle.
|
||||
function makeCartoonStar(base: { r: number; g: number; b: number }): HTMLCanvasElement {
|
||||
const s = SPRITE_PX
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = s
|
||||
cv.height = s
|
||||
const g = cv.getContext('2d')!
|
||||
g.translate(s / 2, s / 2)
|
||||
|
||||
const R = s * 0.4 // outer point radius
|
||||
const r = R * 0.46 // inner radius — small ⇒ chunky, defined points
|
||||
|
||||
// Soft outer glow so the star separates from the night background.
|
||||
const glow = g.createRadialGradient(0, 0, R * 0.3, 0, 0, R * 1.35)
|
||||
glow.addColorStop(0, rgba(base, 0.35))
|
||||
glow.addColorStop(1, rgba(base, 0))
|
||||
g.fillStyle = glow
|
||||
g.beginPath()
|
||||
g.arc(0, 0, R * 1.35, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
// Five-point star path (start at the top point).
|
||||
const star = () => {
|
||||
g.beginPath()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ang = -Math.PI / 2 + (i * Math.PI) / 5
|
||||
const rad = i % 2 === 0 ? R : r
|
||||
const x = Math.cos(ang) * rad
|
||||
const y = Math.sin(ang) * rad
|
||||
if (i === 0) g.moveTo(x, y)
|
||||
else g.lineTo(x, y)
|
||||
}
|
||||
g.closePath()
|
||||
}
|
||||
|
||||
// Candy fill: bright near the top-left, deepening toward the lower edge.
|
||||
const fill = g.createRadialGradient(-R * 0.25, -R * 0.3, R * 0.1, 0, 0, R * 1.1)
|
||||
fill.addColorStop(0, rgba(mixWhite(base, 0.7), 1))
|
||||
fill.addColorStop(0.5, rgba(base, 1))
|
||||
fill.addColorStop(1, rgba(scale(base, 0.82), 1))
|
||||
star()
|
||||
g.fillStyle = fill
|
||||
g.fill()
|
||||
|
||||
// Puffy outline in a deeper shade of the same hue (round joins soften the
|
||||
// points so it feels hand-drawn, not spiky).
|
||||
g.lineJoin = 'round'
|
||||
g.lineCap = 'round'
|
||||
g.lineWidth = s * 0.05
|
||||
g.strokeStyle = rgba(scale(base, 0.5), 0.95)
|
||||
star()
|
||||
g.stroke()
|
||||
|
||||
// Glossy shine: a small soft white blob up in the top-left lobe.
|
||||
const shine = g.createRadialGradient(-R * 0.22, -R * 0.32, 0, -R * 0.22, -R * 0.32, R * 0.3)
|
||||
shine.addColorStop(0, 'rgba(255,255,255,0.85)')
|
||||
shine.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
g.fillStyle = shine
|
||||
g.beginPath()
|
||||
g.ellipse(-R * 0.22, -R * 0.32, R * 0.26, R * 0.18, -0.5, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
export function PetalFall() {
|
||||
interface Petal {
|
||||
baseX: number
|
||||
y: number
|
||||
size: number
|
||||
vy: number // fall speed (px/s)
|
||||
angle: number
|
||||
spin: number // rad/s
|
||||
swayAmp: number
|
||||
swayFreq: number
|
||||
swayPhase: number
|
||||
alpha: number
|
||||
sprite: number
|
||||
}
|
||||
|
||||
export function PetalFall({ night = false }: { night?: boolean }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -124,120 +219,121 @@ export function PetalFall() {
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
if (reduce) return // respect users who don't want ambient motion
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true })
|
||||
if (!gl) return // no WebGL — silently skip the effect
|
||||
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
|
||||
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)
|
||||
if (!vs || !fs) return
|
||||
const prog = gl.createProgram()!
|
||||
gl.attachShader(prog, vs)
|
||||
gl.attachShader(prog, fs)
|
||||
gl.linkProgram(prog)
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
console.error('petal program link failed', gl.getProgramInfoLog(prog))
|
||||
return
|
||||
}
|
||||
gl.useProgram(prog)
|
||||
|
||||
// Build interleaved vertex data: 6 verts per petal, each with corner(2),
|
||||
// rand(4), rand2(4) = 10 floats.
|
||||
const FLOATS_PER_VERT = 10
|
||||
const corners = [
|
||||
[-0.5, -0.5],
|
||||
[0.5, -0.5],
|
||||
[-0.5, 0.5],
|
||||
[-0.5, 0.5],
|
||||
[0.5, -0.5],
|
||||
[0.5, 0.5],
|
||||
]
|
||||
const data = new Float32Array(PETAL_COUNT * 6 * FLOATS_PER_VERT)
|
||||
let o = 0
|
||||
for (let i = 0; i < PETAL_COUNT; i++) {
|
||||
const lane = rand()
|
||||
const speed = 0.018 + rand() * 0.03 // slow, gentle fall
|
||||
const size = 0.05 + rand() * 0.06
|
||||
const phase = rand()
|
||||
const swayAmp = 0.04 + rand() * 0.09
|
||||
const swayFreq = 0.4 + rand() * 0.7
|
||||
const spin = (rand() - 0.5) * 1.2
|
||||
const tint = Math.floor(rand() * TINTS.length)
|
||||
for (let c = 0; c < 6; c++) {
|
||||
data[o++] = corners[c][0]
|
||||
data[o++] = corners[c][1]
|
||||
data[o++] = lane
|
||||
data[o++] = speed
|
||||
data[o++] = size
|
||||
data[o++] = phase
|
||||
data[o++] = swayAmp
|
||||
data[o++] = swayFreq
|
||||
data[o++] = spin
|
||||
data[o++] = tint
|
||||
}
|
||||
}
|
||||
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
|
||||
|
||||
const stride = FLOATS_PER_VERT * 4
|
||||
const aCorner = gl.getAttribLocation(prog, 'a_corner')
|
||||
const aRand = gl.getAttribLocation(prog, 'a_rand')
|
||||
const aRand2 = gl.getAttribLocation(prog, 'a_rand2')
|
||||
gl.enableVertexAttribArray(aCorner)
|
||||
gl.vertexAttribPointer(aCorner, 2, gl.FLOAT, false, stride, 0)
|
||||
gl.enableVertexAttribArray(aRand)
|
||||
gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, stride, 2 * 4)
|
||||
gl.enableVertexAttribArray(aRand2)
|
||||
gl.vertexAttribPointer(aRand2, 4, gl.FLOAT, false, stride, 6 * 4)
|
||||
|
||||
const uTime = gl.getUniformLocation(prog, 'u_time')
|
||||
const uRes = gl.getUniformLocation(prog, 'u_res')
|
||||
const uTints = gl.getUniformLocation(prog, 'u_tints')
|
||||
gl.uniform3fv(uTints, TINTS.flat())
|
||||
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.disable(gl.DEPTH_TEST)
|
||||
|
||||
const el = canvasRef.current
|
||||
if (!el) return
|
||||
const c2d = el.getContext('2d')
|
||||
if (!c2d) return
|
||||
// Non-null aliases so the render closures keep the narrowed types.
|
||||
const view = canvas
|
||||
const glc = gl
|
||||
const canvas = el
|
||||
const ctx = c2d
|
||||
|
||||
// Night sky = chunky cartoon stars (the stars of the show) with a couple of
|
||||
// small twinkle sparkles mixed in for depth. Random sprite pick weights it
|
||||
// ~70% chunky stars.
|
||||
const sprites = night
|
||||
? [...CARTOON_COLORS.map(makeCartoonStar), ...STAR_PALETTE.map(makeStarSprite)]
|
||||
: PALETTE.map(makeSprite)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
let W = window.innerWidth
|
||||
let H = window.innerHeight
|
||||
|
||||
// Scale petal count to the viewport so it stays sparse and gentle.
|
||||
const count = Math.round(Math.min(46, Math.max(16, (W * H) / 46000)))
|
||||
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
|
||||
|
||||
// Stars are smaller, drift down a touch slower, sway less, and barely spin
|
||||
// (they twinkle in place instead of tumbling like petals).
|
||||
const spawn = (initial: boolean): Petal =>
|
||||
night
|
||||
? {
|
||||
baseX: rnd(0, W),
|
||||
y: initial ? rnd(-H, H) : rnd(-60, -20),
|
||||
size: rnd(13, 32),
|
||||
vy: rnd(14, 36),
|
||||
angle: rnd(0, Math.PI * 2),
|
||||
// Every star visibly spins — random direction, and a guaranteed
|
||||
// minimum speed (a 5-point star is symmetric every 72°, so a slow
|
||||
// rate reads as motionless). Varied so no two turn quite alike.
|
||||
spin: (Math.random() < 0.5 ? -1 : 1) * rnd(0.5, 2.2),
|
||||
swayAmp: 0, // stars fall straight down — no petal-like drift
|
||||
swayFreq: rnd(0.4, 1.1), // still drives the twinkle shimmer below
|
||||
swayPhase: rnd(0, Math.PI * 2),
|
||||
alpha: rnd(0.55, 0.9),
|
||||
sprite: Math.floor(Math.random() * sprites.length),
|
||||
}
|
||||
: {
|
||||
baseX: rnd(0, W),
|
||||
y: initial ? rnd(-H, H) : rnd(-60, -20),
|
||||
size: rnd(16, 38),
|
||||
vy: rnd(22, 52),
|
||||
angle: rnd(0, Math.PI * 2),
|
||||
spin: rnd(-0.9, 0.9),
|
||||
swayAmp: rnd(14, 42),
|
||||
swayFreq: rnd(0.3, 0.85),
|
||||
swayPhase: rnd(0, Math.PI * 2),
|
||||
alpha: rnd(0.5, 0.82),
|
||||
sprite: Math.floor(Math.random() * sprites.length),
|
||||
}
|
||||
|
||||
let petals = Array.from({ length: count }, () => spawn(true))
|
||||
|
||||
function resize() {
|
||||
const w = Math.floor(window.innerWidth * dpr)
|
||||
const h = Math.floor(window.innerHeight * dpr)
|
||||
if (view.width !== w || view.height !== h) {
|
||||
view.width = w
|
||||
view.height = h
|
||||
}
|
||||
glc.viewport(0, 0, view.width, view.height)
|
||||
glc.uniform2f(uRes, view.width, view.height)
|
||||
W = window.innerWidth
|
||||
H = window.innerHeight
|
||||
canvas.width = Math.floor(W * dpr)
|
||||
canvas.height = Math.floor(H * dpr)
|
||||
canvas.style.width = `${W}px`
|
||||
canvas.style.height = `${H}px`
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
let raf = 0
|
||||
let running = true
|
||||
const start = performance.now()
|
||||
function frame() {
|
||||
let last = performance.now()
|
||||
|
||||
function frame(now: number) {
|
||||
if (!running) return
|
||||
const t = (performance.now() - start) / 1000
|
||||
glc.clearColor(0, 0, 0, 0)
|
||||
glc.clear(glc.COLOR_BUFFER_BIT)
|
||||
glc.uniform1f(uTime, t)
|
||||
glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6)
|
||||
const dt = Math.min(0.05, (now - last) / 1000) // clamp big gaps (tab wake)
|
||||
last = now
|
||||
const t = now / 1000
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
for (const p of petals) {
|
||||
p.y += p.vy * dt
|
||||
p.angle += p.spin * dt
|
||||
const x = p.baseX + p.swayAmp * Math.sin(t * p.swayFreq + p.swayPhase)
|
||||
|
||||
// Recycle once fully past the bottom.
|
||||
if (p.y - p.size > H) {
|
||||
Object.assign(p, spawn(false))
|
||||
continue
|
||||
}
|
||||
|
||||
// Stars shimmer gently (shallow pulse so the chunky ones glow rather
|
||||
// than blink); petals hold a steady alpha.
|
||||
const alpha = night
|
||||
? p.alpha * (0.8 + 0.2 * Math.sin(t * p.swayFreq * 2.2 + p.swayPhase))
|
||||
: p.alpha
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, p.y)
|
||||
ctx.rotate(p.angle)
|
||||
ctx.globalAlpha = alpha
|
||||
ctx.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size)
|
||||
ctx.restore()
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
// Pause when the tab is hidden to save the battery.
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
} else if (!running) {
|
||||
running = true
|
||||
last = performance.now()
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
@@ -249,12 +345,9 @@ export function PetalFall() {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', resize)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
gl.deleteBuffer(buf)
|
||||
gl.deleteProgram(prog)
|
||||
gl.deleteShader(vs)
|
||||
gl.deleteShader(fs)
|
||||
petals = []
|
||||
}
|
||||
}, [])
|
||||
}, [night])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@@ -20,17 +20,29 @@ export function useCheckpoint(docId: string | null) {
|
||||
const [llmDown, setLlmDown] = useState(false)
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
// Pending auto-retry timer for a failed checkpoint (see runCheck).
|
||||
const retryRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const docIdRef = useRef(docId)
|
||||
docIdRef.current = docId
|
||||
|
||||
// Token to discard responses from a doc we've since navigated away from.
|
||||
const runRef = useRef(0)
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
// Backoff schedule for re-running a checkpoint that failed. A paste fires
|
||||
// exactly ONE checkpoint, and nothing re-fires until the next keystroke — so a
|
||||
// single transient failure (a momentary stall on the shared inference box)
|
||||
// would otherwise strand the user on "resting" indefinitely after a paste.
|
||||
// These delays clear the server's 30s per-doc floor by the later attempts, and
|
||||
// a failed pass now releases its slot server-side so a retry can truly re-run.
|
||||
const RETRY_DELAYS_MS = [3000, 12000, 35000]
|
||||
|
||||
const runCheck = useCallback(async (attempt = 0) => {
|
||||
const id = docIdRef.current
|
||||
if (!id) return
|
||||
clearTimeout(retryRef.current)
|
||||
const run = ++runRef.current
|
||||
setChecking(true)
|
||||
let retrying = false
|
||||
try {
|
||||
const fresh = await api.checkDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
@@ -39,9 +51,18 @@ export function useCheckpoint(docId: string | null) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('checkpoint failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
if (run !== runRef.current) return
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
// Keep trying quietly — don't flag "resting" until retries are exhausted.
|
||||
retrying = true
|
||||
retryRef.current = setTimeout(() => void runCheck(attempt + 1), RETRY_DELAYS_MS[attempt])
|
||||
} else {
|
||||
setLlmDown(true)
|
||||
}
|
||||
} finally {
|
||||
if (run === runRef.current) setChecking(false)
|
||||
// Stay in the "checking" state while a retry is queued so the breathing dot
|
||||
// keeps reassuring rather than flickering off between attempts.
|
||||
if (run === runRef.current && !retrying) setChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -51,6 +72,7 @@ export function useCheckpoint(docId: string | null) {
|
||||
const runVoice = useCallback(async () => {
|
||||
const id = docIdRef.current
|
||||
if (!id) return
|
||||
clearTimeout(retryRef.current) // a voice pass supersedes a queued grammar retry
|
||||
const run = ++runRef.current
|
||||
setVoicing(true)
|
||||
try {
|
||||
@@ -70,13 +92,15 @@ export function useCheckpoint(docId: string | null) {
|
||||
// Call on every edit; schedules a check 4s after typing settles.
|
||||
const schedule = useCallback(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(runCheck, DEBOUNCE_MS)
|
||||
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
|
||||
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
|
||||
}, [runCheck])
|
||||
|
||||
// Load existing pending suggestions whenever the document changes, and cancel
|
||||
// any in-flight debounce from the previous doc.
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
clearTimeout(retryRef.current)
|
||||
runRef.current++
|
||||
setSuggestions([])
|
||||
setChecking(false)
|
||||
@@ -97,7 +121,13 @@ export function useCheckpoint(docId: string | null) {
|
||||
}
|
||||
}, [docId])
|
||||
|
||||
useEffect(() => () => clearTimeout(debounceRef.current), [])
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(debounceRef.current)
|
||||
clearTimeout(retryRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Drop one suggestion locally (after accept/dismiss) without a refetch.
|
||||
const removeSuggestion = useCallback((id: string) => {
|
||||
|
||||
25
web/src/hooks/useNightMode.ts
Normal file
25
web/src/hooks/useNightMode.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isBedtime } from '../lib/night'
|
||||
|
||||
// Tracks whether it's late enough to shift Petal into its calm night mode (dark
|
||||
// theme + falling stars instead of petals). Re-checks once a minute so the swap
|
||||
// happens on its own as the clock rolls past 11pm — no reload needed. Returns
|
||||
// the boolean and also toggles a `petal-night` class on <html> so the CSS token
|
||||
// overrides cascade to everything (body background, panels, portals included).
|
||||
export function useNightMode(): boolean {
|
||||
const [night, setNight] = useState(() => isBedtime())
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => setNight(isBedtime())
|
||||
tick()
|
||||
const id = setInterval(tick, 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('petal-night', night)
|
||||
return () => document.documentElement.classList.remove('petal-night')
|
||||
}, [night])
|
||||
|
||||
return night
|
||||
}
|
||||
@@ -37,6 +37,23 @@
|
||||
--shadow-soft: 0 4px 20px rgba(180, 130, 160, 0.12);
|
||||
}
|
||||
|
||||
/* Night mode — toggled by adding `petal-night` to <html> (useNightMode) once the
|
||||
clock passes ~11pm. We only re-point the palette tokens; every Tailwind color
|
||||
utility reads them via var(), so the whole UI shifts to a calm, dim, moonlit
|
||||
theme without touching component markup. Pastel accent/type colors are kept —
|
||||
they read beautifully on the dark plum ground. Falling petals become stars. */
|
||||
html.petal-night {
|
||||
--color-bg: #14111E; /* deep night plum-indigo */
|
||||
--color-surface: #211C30; /* raised panels */
|
||||
--color-surface-alt: #2A2440; /* alt surface / hover wash */
|
||||
--color-border: #38304E; /* dim lavender border */
|
||||
--color-plum: #ECE3F2; /* ink → soft moonlit lavender-white */
|
||||
--color-muted: #9F93B8; /* muted lavender-grey, lifted for contrast */
|
||||
--color-accent: #E8A0BF; /* rose still primary — pops on dark */
|
||||
--color-accent-hover: #F2B7D2;/* lighter on hover against the dark */
|
||||
--shadow-soft: 0 6px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -47,6 +64,8 @@ body {
|
||||
color: var(--color-plum);
|
||||
font-family: var(--font-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Gentle dusk/dawn fade when night mode flips. */
|
||||
transition: background 600ms ease, color 600ms ease;
|
||||
}
|
||||
|
||||
/* Gentle, consistent motion across interactive elements */
|
||||
@@ -98,6 +117,93 @@ button, a, input {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Top formatting bar: a single line at rest so it stays slim, expanding to
|
||||
reveal every control on hover (or while a control inside it has focus, e.g.
|
||||
the link input or an open popover). The clipped right edge fades out as a hint
|
||||
that more is available. */
|
||||
.petal-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
-webkit-mask-image: linear-gradient(to right, #000 90%, transparent 100%);
|
||||
mask-image: linear-gradient(to right, #000 90%, transparent 100%);
|
||||
}
|
||||
.petal-toolbar:hover,
|
||||
.petal-toolbar:focus-within {
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* Highlighter mark — inline background swatch behind the text. */
|
||||
.petal-prose mark {
|
||||
border-radius: 4px;
|
||||
padding: 0.05em 0.15em;
|
||||
/* color comes from the inline style the Highlight extension writes */
|
||||
}
|
||||
/* Inserted images: rounded, soft-shadowed, never wider than the column. The
|
||||
selected-node ring matches the rest of the rose theme. Scoped to the editor's
|
||||
own <img> (not a class) since Tiptap doesn't always carry the configured
|
||||
class through to the rendered node. */
|
||||
.petal-prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.petal-prose img.ProseMirror-selectednode {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Tables: soft pink grid, rounded outer corners, a tinted header row. Targeted
|
||||
by element within the editor — Tiptap renders the <table> without our
|
||||
configured class, so a `.petal-table` selector would never match. The wrapper
|
||||
Tiptap adds (.tableWrapper) handles horizontal overflow scroll. */
|
||||
.petal-prose .tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.petal-prose table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.4em 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-input);
|
||||
border: 1px solid var(--color-border);
|
||||
table-layout: fixed;
|
||||
}
|
||||
.petal-prose td,
|
||||
.petal-prose th {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.4em 0.6em;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
min-width: 3em;
|
||||
}
|
||||
.petal-prose th {
|
||||
background: var(--color-surface-alt);
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
/* The cell being edited / the active selection inside a table. */
|
||||
.petal-prose .selectedCell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-surface-alt);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The column-resize handle Tiptap draws between columns. */
|
||||
.petal-prose .column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 4px;
|
||||
background: var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Placeholder shown on the empty first paragraph (Tiptap Placeholder ext). */
|
||||
.petal-prose p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
@@ -136,6 +242,81 @@ button, a, input {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* Text emphasized from its margin card (or a hover) — a soft wash so the
|
||||
card↔text link reads at a glance, both directions. */
|
||||
.petal-suggestion-active {
|
||||
background: var(--color-surface-alt);
|
||||
box-shadow: 0 0 0 3px var(--color-surface-alt);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* --- Suggestion margin rail -------------------------------------------------
|
||||
The right-hand "comment column": every outstanding suggestion as a card,
|
||||
vertically aligned to the text it flags, so the whole queue is visible at a
|
||||
glance instead of one-highlight-at-a-time on hover. It floats in the whitespace
|
||||
just right of the editor column and is only mounted when there's room for it
|
||||
(EditorCore measures the gap). Cards are absolutely positioned by a resolved
|
||||
top (anchor + collision avoidance), so the container only anchors the column. */
|
||||
.petal-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
margin-left: 32px;
|
||||
width: 300px;
|
||||
/* Below the companion mascot (z-40): where a stacked card reaches the
|
||||
bottom-right corner it simply tucks behind the sleeping cat. */
|
||||
z-index: 10;
|
||||
}
|
||||
.petal-rail-card {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-border); /* type color set inline */
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 0.7rem 0.85rem;
|
||||
/* `top` eases so re-stacking (accept/dismiss/edit) glides instead of jumping.
|
||||
The entrance uses fill `backwards` so its translateY doesn't linger and fight
|
||||
the active-state transform once it's done. */
|
||||
transition: top 240ms cubic-bezier(0.2, 0.7, 0.3, 1), box-shadow 200ms ease,
|
||||
transform 200ms ease, border-color 200ms ease;
|
||||
animation: petal-suggestion-in 220ms ease backwards;
|
||||
}
|
||||
.petal-rail-card-active {
|
||||
transform: translateX(-5px);
|
||||
box-shadow: 0 8px 26px rgba(180, 130, 160, 0.24);
|
||||
border-top-color: var(--color-accent);
|
||||
border-right-color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
.petal-rail-x {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.petal-rail-x:hover {
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-plum);
|
||||
}
|
||||
|
||||
/* --- 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
|
||||
@@ -218,9 +399,17 @@ button, a, input {
|
||||
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
||||
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
||||
.petal-companion {
|
||||
/* Mascot size scales with the viewport width: ~original on a laptop, up to
|
||||
~2× on a large desktop. Tune the middle (vw) term to taste. */
|
||||
--petal-companion-size: clamp(10rem, 17vw, 20rem);
|
||||
animation: petal-bob 3.2s ease-in-out infinite;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
/* The Lottie art sits inside the round badge with a little breathing room. */
|
||||
.petal-companion-art {
|
||||
width: 88%;
|
||||
height: 88%;
|
||||
}
|
||||
.petal-companion:hover {
|
||||
transform: translateY(-2px) scale(1.04);
|
||||
}
|
||||
@@ -246,10 +435,10 @@ button, a, input {
|
||||
}
|
||||
|
||||
.petal-zzz {
|
||||
top: 6px;
|
||||
right: 14px;
|
||||
top: calc(var(--petal-companion-size) * 0.04);
|
||||
right: calc(var(--petal-companion-size) * 0.1);
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
font-size: calc(var(--petal-companion-size) * 0.12);
|
||||
animation: petal-zzz 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes petal-zzz {
|
||||
@@ -388,7 +577,7 @@ button, a, input {
|
||||
the writing. It hides itself in print and is skipped entirely for users who
|
||||
prefer reduced motion (the component renders nothing in that case). */
|
||||
.petal-fall {
|
||||
opacity: 0.85;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Honor reduced-motion globally: still the looping ambient animations and drop
|
||||
|
||||
11
web/src/lib/night.ts
Normal file
11
web/src/lib/night.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Shared "is it late?" definition, used by both the companion's bedtime nag and
|
||||
// the night-mode theme/starfall switch so they always agree. Local clock — the
|
||||
// writer's own machine (millenia's timezone in deployment).
|
||||
|
||||
export const BEDTIME_FROM = 23 // local hour the night window opens (11pm)…
|
||||
export const BEDTIME_TO = 4 // …and closes (4am); past this we assume an early start
|
||||
|
||||
export function isBedtime(d: Date = new Date()): boolean {
|
||||
const h = d.getHours()
|
||||
return h >= BEDTIME_FROM || h < BEDTIME_TO
|
||||
}
|
||||
Reference in New Issue
Block a user