Compare commits
14 Commits
feat/petal
...
1bd38b20e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,16 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
|
||||
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
|
||||
|
||||
### Phase 11 — Writer power-ups ✅
|
||||
- [x] **In-document Find & Replace (Ctrl/Cmd+F)** — `SearchHighlight` ProseMirror extension (decorations, not marks — same anchoring discipline as the suggestion/spell layers; matches recomputed per-textblock on every edit, never stranded). `FindReplace` bar (bilingual zh-first): live match highlighting, ↑/↓ step-through with no-selection DOM scroll-into-view (so it never pops the rewrite bubble), match-case toggle, replace / replace-all (replace-all applies back-to-front so earlier edits don't shift later positions). Honey wash on all matches, rose ring on the active one.
|
||||
- [x] **Read-aloud / TTS** (`web/src/audio/speech.ts`) — Web Speech API, offline, feature-detected. 🔊 in the `WordCard` (pronounce the word) and the selection bubble (read the selection). Pairs with the phonetic line.
|
||||
- [x] **Keyboard + touch access to the ESL helpers** — refactored the right-click word-lookup into a position-based `openWordLookup(pos)`; now also driven by **Ctrl/Cmd+D** (look up the word at the caret) and a **touch long-press** (~500ms, the touch equivalent of right-click). **Ctrl/Cmd+J** rewrites the selection more naturally. (Closes the "pointer-only" limitation noted in Phase 10.)
|
||||
- [x] **Whole-corpus backup** — `GET /api/docs/export-all?format=md|docx|…` zips every doc (reuses the per-doc renderers; de-duplicates same-titled filenames; dated `petal-backup-YYYY-MM-DD.zip`). Static route takes priority over `/{id}` in chi — covered by `TestExportAll`. Sidebar footer "备份 · Back up all: Word / Markdown" download links.
|
||||
- [x] **Smart typography** (`Typography.ts`) — dependency-free input rules: curly quotes, em-dash (`--`), ellipsis (`...`). ASCII-only triggers so CJK fullwidth punctuation is untouched; every rule is plain-Undo-able.
|
||||
- [x] **Org niceties** — duplicate-a-document (App `handleDuplicate` → "… (副本)", copies body/tone, not tags), sidebar **sort** (Recent / Title / Longest), and a **document outline** popover in the toolbar (headings → click to scroll, indented by level).
|
||||
- [x] **English phonetic (pivot from pinyin)** — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently). `scripts/build_phonetic.py` extracts ECDICT's `phonetic` column (same source/freq-gate as the gloss); `phonetic.json.gz` embedded + lazily loaded; `Result.Phonetic` resolved via the same de-inflecting candidate walk; shown as `/ˈrɪvər/` in the WordCard. **Full dataset built from ECDICT: 46,579 words (361KB gz)**, in line with the other lexicon assets. The script also has a `--seed` mode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist.
|
||||
- Verified: go build/vet/test (incl. new `TestExportAll`), tsc, vite build all clean; live smoke vs throwaway binaries — `/word/river|running|serendipity|rivers` all return phonetic (`rivers`→`river` de-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with `/{id}`.
|
||||
|
||||
### Deferred (post-v1-local)
|
||||
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
||||
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||
@@ -105,6 +115,7 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
||||
|
||||
## Session log
|
||||
- 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace** — `SearchHighlight` decoration extension + `FindReplace` bar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) **read-aloud** Web Speech util + 🔊 in WordCard & selection bubble; (3) **keyboard/touch access** — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactored `handleContextMenu` → shared `openWordLookup(pos)`); (4) **export-all** backup zip (`GET /api/docs/export-all`, `TestExportAll`, sidebar download links); (5) **smart typography** input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) **duplicate doc + sidebar sort + outline popover**; (7) **English phonetic** (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) via `scripts/build_phonetic.py` + embedded `phonetic.json.gz` + `Result.Phonetic` + WordCard `/ˈrɪvər/` line — **full 46,579-word dataset built from ECDICT** (the csv re-download worked; `--seed` mode kept as a csv-free fallback). Also folded in this session: the **selection-bubble vs copy/paste fix** (bubble deferred to pointer-up + container `pointer-events:none` so it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user.
|
||||
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
||||
- 2026-06-26: **Phase 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -154,6 +155,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 +177,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 +203,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",
|
||||
|
||||
@@ -192,6 +192,38 @@ export default function App() {
|
||||
setDocText('')
|
||||
}, [saveNow, isBlankDraft])
|
||||
|
||||
// Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)",
|
||||
// then open the copy. Tags aren't carried over (a fresh start for the copy).
|
||||
const handleDuplicate = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await saveNow() // flush in case we're duplicating the open doc
|
||||
const src = await api.getDoc(id)
|
||||
const fresh = await api.createDoc()
|
||||
const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)`
|
||||
const updated = await api.updateDoc(fresh.id, {
|
||||
title: dupTitle,
|
||||
content: src.content,
|
||||
content_text: src.content_text,
|
||||
tone: src.tone,
|
||||
word_count: src.word_count,
|
||||
})
|
||||
setDocs((prev) => [
|
||||
{ id: updated.id, title: updated.title, word_count: updated.word_count, updated_at: updated.updated_at, tags: [] },
|
||||
...prev,
|
||||
])
|
||||
setCurrentDoc(updated)
|
||||
setTitle(updated.title)
|
||||
setWordCount(updated.word_count)
|
||||
setTone(updated.tone || 'general')
|
||||
setDocText(updated.content_text)
|
||||
} catch (err) {
|
||||
console.error('duplicate failed', err)
|
||||
}
|
||||
},
|
||||
[saveNow],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
await api.deleteDoc(id)
|
||||
@@ -371,6 +403,7 @@ export default function App() {
|
||||
onSelect={openDoc}
|
||||
onCreate={handleCreate}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onToggleTag={handleToggleTag}
|
||||
onCreateTag={handleCreateTag}
|
||||
/>
|
||||
@@ -475,6 +508,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
|
||||
|
||||
104
web/src/audio/speech.ts
Normal file
104
web/src/audio/speech.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
|
||||
// don't queue up. `lang` defaults to US English (the language she's learning);
|
||||
// pass a zh locale to hear a Chinese passage. 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 = 'en-US'): 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'
|
||||
|
||||
@@ -48,8 +48,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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import {
|
||||
BREAKS,
|
||||
ENCOURAGEMENTS,
|
||||
ERRORS,
|
||||
GREETING,
|
||||
MILESTONES,
|
||||
TIPS,
|
||||
@@ -12,13 +13,13 @@ import {
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
import { playSound } from '../../audio/sounds'
|
||||
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
||||
|
||||
// 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'
|
||||
export interface Bubble extends Line {
|
||||
tone: BubbleTone
|
||||
}
|
||||
@@ -26,6 +27,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
|
||||
@@ -43,10 +47,10 @@ const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||
// 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()
|
||||
|
||||
@@ -62,7 +66,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)
|
||||
|
||||
@@ -88,7 +92,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 +101,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 +201,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) {
|
||||
|
||||
@@ -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,118 +1,84 @@
|
||||
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;
|
||||
const SPRITE_PX = 160 // off-screen render resolution per petal sprite
|
||||
|
||||
void main() {
|
||||
v_uv = a_corner + 0.5;
|
||||
v_tint = a_rand2.w;
|
||||
|
||||
float lane = a_rand.x;
|
||||
float speed = a_rand.y;
|
||||
float size = a_rand.z;
|
||||
float phase = a_rand.w;
|
||||
|
||||
// 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
|
||||
}
|
||||
return sh
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
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() {
|
||||
@@ -124,120 +90,90 @@ 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
|
||||
|
||||
const sprites = 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)
|
||||
|
||||
const spawn = (initial: boolean): Petal => ({
|
||||
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
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, p.y)
|
||||
ctx.rotate(p.angle)
|
||||
ctx.globalAlpha = p.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,10 +185,7 @@ 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 = []
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -98,6 +98,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 +223,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 +380,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 +416,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 +558,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
|
||||
|
||||
Reference in New Issue
Block a user