8 Commits

Author SHA1 Message Date
prosolis
db737fa612 Editor: Find & Replace, read-aloud, backup, typography, phonetic, org niceties
Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix.

- Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension +
  FindReplace bar (match-case, replace-all back-to-front, scroll without
  popping the selection bubble).
- Read-aloud (Web Speech, offline) on the word card and selection bubble.
- Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret,
  Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the
  right-click handler into a shared openWordLookup(pos).
- Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx),
  de-dupes filenames, dated name; sidebar download links. TestExportAll.
- Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so
  CJK is untouched.
- Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover.
- English phonetic (chosen over pinyin for an English learner): ECDICT-built
  phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line;
  scripts/build_phonetic.py (full build + --seed fallback).
- Selection bubble no longer blocks copy/paste: deferred to pointer-up and made
  click-through except on its buttons.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:47:26 -07:00
prosolis
6e6e4edce7 Editor: font-size presets + image insert/export support
Two editor features that were in flight alongside the sound work:
- FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/
  Title presets in the toolbar; StatusBar + CSS support
- Image handling: internal/images handler, upload route + config, client
  API, EditorCore wiring, and md/html/docx export support for images

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:05:40 -07:00
prosolis
58c326d0cf Notification sounds: hand-picked mp3 palette + error/milestone cues
Replace the synthesized WAV blips with curated mp3 effects:
- error ("haiya") plays on LLM-down/timeout and save failures, debounced
  and on-transition-only, with a reassuring bilingual bubble
- milestone (Chinese fanfare) now fires every 100 words
- all other notifications draw from a rotating pop pool that never repeats
  the last sound, so batches of suggestions/tips stay lively

playPop() replaces the per-suggestion-type sound map; thread llmDown through
PetalCompanion -> useCompanion for the error cue. Old synth WAVs removed.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:05:05 -07:00
prosolis
045ec19b92 Petals: 2D sprite blossoms instead of shader blobs
The procedural WebGL petals read as faint smudges. Replace with a 2D
canvas that pre-renders real notched sakura petal sprites (tip→base
gradient, soft central sheen, transparent edges) and blits falling,
rotating, swaying instances. Count scales with viewport; pauses when
hidden; skipped under prefers-reduced-motion.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:47:13 -07:00
prosolis
f6fa73b17b Petals: saturated blossom tints + glossy shading
The pale pastel tints washed out to grey over the cream canvas. Use
saturated cherry-blossom colors, add a luminous centre + soft base
shadow per petal, and render at full opacity so they read as colorful.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:41:53 -07:00
prosolis
8cf78130bf Merge: ambient WebGL petals + cute notification sounds
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:34:46 -07:00
prosolis
2487e73551 Ambient WebGL petals + cute notification sounds
Add a fixed, GPU-driven petal layer that drifts soft blossoms over the
page (sparse, translucent, paused when hidden, skipped under
prefers-reduced-motion). Add a synthesized cute sound palette — bubble
pop, water droplet, soft bell, sparkle, cheer — generated as embedded
WAV assets (scripts/gen_sounds.py) and played through a quiet Web Audio
bus with soft-clipping and a persisted mute toggle in the status bar.

Sounds fire per suggestion type when fresh advice arrives (staggered,
old pending advice stays silent) and on companion bubbles by tone.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:34:23 -07:00
prosolis
0a1ea225dd Merge: companion rules-based writing advice + tests 2026-06-26 07:16:45 -07:00
44 changed files with 3048 additions and 62 deletions

View File

@@ -7,6 +7,9 @@ BASE_URL=http://localhost:8080
# Database (SQLite, pure-Go modernc — no cgo) # Database (SQLite, pure-Go modernc — no cgo)
DATABASE_PATH=./data/petal.db DATABASE_PATH=./data/petal.db
# On-disk store for images pasted/dropped/inserted in the editor
IMAGE_DIR=./data/images
# LLM # LLM
LLM_BACKEND=vllm # vllm | ollama LLM_BACKEND=vllm # vllm | ollama
LLM_ENDPOINT=http://localhost:8000 # vLLM :8000, Ollama :11434 LLM_ENDPOINT=http://localhost:8000 # vLLM :8000, Ollama :11434

View File

@@ -95,6 +95,16 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately. - Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable. - **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
### Phase 11 — Writer power-ups ✅
- [x] **In-document Find & Replace (Ctrl/Cmd+F)**`SearchHighlight` ProseMirror extension (decorations, not marks — same anchoring discipline as the suggestion/spell layers; matches recomputed per-textblock on every edit, never stranded). `FindReplace` bar (bilingual zh-first): live match highlighting, ↑/↓ step-through with no-selection DOM scroll-into-view (so it never pops the rewrite bubble), match-case toggle, replace / replace-all (replace-all applies back-to-front so earlier edits don't shift later positions). Honey wash on all matches, rose ring on the active one.
- [x] **Read-aloud / TTS** (`web/src/audio/speech.ts`) — Web Speech API, offline, feature-detected. 🔊 in the `WordCard` (pronounce the word) and the selection bubble (read the selection). Pairs with the phonetic line.
- [x] **Keyboard + touch access to the ESL helpers** — refactored the right-click word-lookup into a position-based `openWordLookup(pos)`; now also driven by **Ctrl/Cmd+D** (look up the word at the caret) and a **touch long-press** (~500ms, the touch equivalent of right-click). **Ctrl/Cmd+J** rewrites the selection more naturally. (Closes the "pointer-only" limitation noted in Phase 10.)
- [x] **Whole-corpus backup**`GET /api/docs/export-all?format=md|docx|…` zips every doc (reuses the per-doc renderers; de-duplicates same-titled filenames; dated `petal-backup-YYYY-MM-DD.zip`). Static route takes priority over `/{id}` in chi — covered by `TestExportAll`. Sidebar footer "备份 · Back up all: Word / Markdown" download links.
- [x] **Smart typography** (`Typography.ts`) — dependency-free input rules: curly quotes, em-dash (`--`), ellipsis (`...`). ASCII-only triggers so CJK fullwidth punctuation is untouched; every rule is plain-Undo-able.
- [x] **Org niceties** — duplicate-a-document (App `handleDuplicate` → "… (副本)", copies body/tone, not tags), sidebar **sort** (Recent / Title / Longest), and a **document outline** popover in the toolbar (headings → click to scroll, indented by level).
- [x] **English phonetic (pivot from pinyin)** — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently). `scripts/build_phonetic.py` extracts ECDICT's `phonetic` column (same source/freq-gate as the gloss); `phonetic.json.gz` embedded + lazily loaded; `Result.Phonetic` resolved via the same de-inflecting candidate walk; shown as `/ˈrɪvər/` in the WordCard. **Full dataset built from ECDICT: 46,579 words (361KB gz)**, in line with the other lexicon assets. The script also has a `--seed` mode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist.
- Verified: go build/vet/test (incl. new `TestExportAll`), tsc, vite build all clean; live smoke vs throwaway binaries — `/word/river|running|serendipity|rivers` all return phonetic (`rivers``river` de-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with `/{id}`.
### Deferred (post-v1-local) ### Deferred (post-v1-local)
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first** - [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
- [ ] Copyleaks Tier-2 + webhook HMAC - [ ] Copyleaks Tier-2 + webhook HMAC
@@ -105,6 +115,7 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE) - [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
## Session log ## Session log
- 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace**`SearchHighlight` decoration extension + `FindReplace` bar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) **read-aloud** Web Speech util + 🔊 in WordCard & selection bubble; (3) **keyboard/touch access** — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactored `handleContextMenu` → shared `openWordLookup(pos)`); (4) **export-all** backup zip (`GET /api/docs/export-all`, `TestExportAll`, sidebar download links); (5) **smart typography** input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) **duplicate doc + sidebar sort + outline popover**; (7) **English phonetic** (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) via `scripts/build_phonetic.py` + embedded `phonetic.json.gz` + `Result.Phonetic` + WordCard `/ˈrɪvər/` line — **full 46,579-word dataset built from ECDICT** (the csv re-download worked; `--seed` mode kept as a csv-free fallback). Also folded in this session: the **selection-bubble vs copy/paste fix** (bubble deferred to pointer-up + container `pointer-events:none` so it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user.
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (07) + post-v1 product (810) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user. - 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (07) + post-v1 product (810) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).** - 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.** - 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**

View File

@@ -15,6 +15,7 @@ import (
"gitea.parodia.dev/drwily/petal/internal/config" "gitea.parodia.dev/drwily/petal/internal/config"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/docs" "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/lexicon"
"gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/llm"
"gitea.parodia.dev/drwily/petal/internal/suggestions" "gitea.parodia.dev/drwily/petal/internal/suggestions"
@@ -80,6 +81,13 @@ func main() {
lex := lexicon.NewHandler() lex := lexicon.NewHandler()
api.Mount("/word", lex.Routes()) api.Mount("/word", lex.Routes())
api.Mount("/gloss", lex.GlossRoutes()) 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())
}) })
// Everything else: serve the embedded SPA (with index.html fallback for client routing). // Everything else: serve the embedded SPA (with index.html fallback for client routing).

View File

@@ -11,6 +11,7 @@ type Config struct {
Port string Port string
BaseURL string BaseURL string
DatabasePath string DatabasePath string
ImageDir string // on-disk store for editor image uploads
// LLM // LLM
LLMBackend string // "vllm" | "ollama" LLMBackend string // "vllm" | "ollama"
@@ -32,6 +33,7 @@ func Load() *Config {
Port: env("PORT", "8080"), Port: env("PORT", "8080"),
BaseURL: env("BASE_URL", "http://localhost:8080"), BaseURL: env("BASE_URL", "http://localhost:8080"),
DatabasePath: env("DATABASE_PATH", "./data/petal.db"), DatabasePath: env("DATABASE_PATH", "./data/petal.db"),
ImageDir: env("IMAGE_DIR", "./data/images"),
LLMBackend: env("LLM_BACKEND", "vllm"), LLMBackend: env("LLM_BACKEND", "vllm"),
LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"), LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"),

View File

@@ -9,15 +9,101 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
) )
// exportRoutes registers the download endpoint: GET /api/docs/{id}/export?format=md|html|txt|docx // exportRoutes registers the download endpoints: one document
// (GET /api/docs/{id}/export?format=…) and a whole-corpus backup zip
// (GET /api/docs/export-all?format=…). The static "export-all" segment takes
// priority over the {id} param in chi's router, so the two don't collide.
func (h *Handler) exportRoutes(r chi.Router) { func (h *Handler) exportRoutes(r chi.Router) {
r.Get("/{id}/export", h.export) r.Get("/{id}/export", h.export)
r.Get("/export-all", h.exportAll)
}
// exportAll streams every document the user owns, each rendered in the requested
// format, bundled into a single zip — a one-click "download all my writing"
// backup. Per-doc version history guards against bad edits; this guards against
// a lost disk. Reuses the same renderers as the single-document export.
func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
formatKey := r.URL.Query().Get("format")
if formatKey == "" {
formatKey = "md"
}
format, ok := exportFormats[formatKey]
if !ok {
badRequest(w, "unsupported export format")
return
}
rows, err := h.DB.Query(
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
FROM documents
WHERE user_id = ?
ORDER BY updated_at DESC`,
db.LocalUserID,
)
if err != nil {
serverError(w, err)
return
}
defer rows.Close()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
seen := map[string]int{} // de-duplicate filenames from same-titled docs
for rows.Next() {
var doc db.Document
if err := rows.Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
); err != nil {
serverError(w, err)
return
}
body, err := format.render(doc)
if err != nil {
serverError(w, err)
return
}
base := sanitizeFilename(doc.Title)
if base == "" {
base = "untitled"
}
key := base + "." + format.ext
name := key
if c := seen[key]; c > 0 {
name = fmt.Sprintf("%s (%d).%s", base, c, format.ext)
}
seen[key]++
f, err := zw.Create(name)
if err != nil {
serverError(w, err)
return
}
if _, err := f.Write(body); err != nil {
serverError(w, err)
return
}
}
if err := rows.Err(); err != nil {
serverError(w, err)
return
}
if err := zw.Close(); err != nil {
serverError(w, err)
return
}
filename := "petal-backup-" + time.Now().Format("2006-01-02") + ".zip"
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
_, _ = w.Write(buf.Bytes())
} }
// exportFormat describes one downloadable format: how to render it and how to // exportFormat describes one downloadable format: how to render it and how to
@@ -92,7 +178,8 @@ type pmNode struct {
} }
type pmMark 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 // 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 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 { func (n pmNode) level() int {
if n.Attrs == nil { if n.Attrs == nil {
return 1 return 1
@@ -173,6 +283,11 @@ func mdBlock(n pmNode, depth int) string {
return "```\n" + textContent(n) + "\n```" return "```\n" + textContent(n) + "\n```"
case "horizontalRule": case "horizontalRule":
return "---" return "---"
case "image":
alt := n.attrStr("alt")
return fmt.Sprintf("![%s](%s)", alt, n.attrStr("src"))
case "table":
return mdTable(n)
case "bulletList", "orderedList": case "bulletList", "orderedList":
var items []string var items []string
for i, item := range n.Content { for i, item := range n.Content {
@@ -213,6 +328,55 @@ func mdInline(nodes []pmNode) string {
return b.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 { func applyMdMarks(n pmNode) string {
t := n.Text t := n.Text
if n.hasMark("code") { if n.hasMark("code") {
@@ -230,6 +394,9 @@ func applyMdMarks(n pmNode) string {
if n.hasMark("underline") { if n.hasMark("underline") {
t = "<u>" + t + "</u>" t = "<u>" + t + "</u>"
} }
if href := n.markAttr("link", "href"); href != "" {
t = "[" + t + "](" + href + ")"
}
return t return t
} }
@@ -347,6 +514,11 @@ func htmlBlock(n pmNode) string {
return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n" return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
case "horizontalRule": case "horizontalRule":
return "<hr>\n" 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": case "bulletList", "orderedList":
tag := "ul" tag := "ul"
if n.Type == "orderedList" { 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 { func htmlInline(nodes []pmNode) string {
var b strings.Builder var b strings.Builder
for _, n := range nodes { for _, n := range nodes {
@@ -408,6 +612,12 @@ func applyHTMLMarks(n pmNode) string {
if n.hasMark("strike") { if n.hasMark("strike") {
t = "<s>" + t + "</s>" 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 return t
} }
@@ -500,6 +710,16 @@ func docxBlock(n pmNode) string {
return b.String() return b.String()
case "horizontalRule": 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>` 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": case "bulletList", "orderedList":
var b strings.Builder var b strings.Builder
for i, item := range n.Content { 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 // 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 // style name (e.g. "Quote") and an optional literal text prefix (for list
// markers) may be supplied. // markers) may be supplied.
@@ -571,6 +833,15 @@ func docxRun(n pmNode) string {
if n.hasMark("strike") { if n.hasMark("strike") {
props.WriteString("<w: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 := "" rpr := ""
if props.Len() > 0 { if props.Len() > 0 {
rpr = "<w:rPr>" + props.String() + "</w:rPr>" rpr = "<w:rPr>" + props.String() + "</w:rPr>"

View File

@@ -61,6 +61,45 @@ func TestExportMarkdown(t *testing.T) {
} }
} }
func TestExportAll(t *testing.T) {
srv := newTestServer(t)
// Two docs, one with a duplicate title to exercise filename de-duplication.
seedRichDoc(t, srv)
id2 := newDoc(t, srv)
if rec := do(t, srv, http.MethodPut, "/"+id2, `{"title":"日记 Diary","content":`+jsonString(richDocJSON)+`,"content_text":"x","word_count":1}`); rec.Code != http.StatusOK {
t.Fatalf("seed second doc: %d %s", rec.Code, rec.Body)
}
rec := do(t, srv, http.MethodGet, "/export-all?format=md", "")
if rec.Code != http.StatusOK {
t.Fatalf("export-all: code=%d body=%s", rec.Code, rec.Body)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
t.Fatalf("unexpected content-type: %q", ct)
}
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
if err != nil {
t.Fatalf("zip open: %v", err)
}
if len(zr.File) != 2 {
t.Fatalf("expected 2 files in backup, got %d", len(zr.File))
}
names := map[string]bool{}
for _, f := range zr.File {
names[f.Name] = true
rc, _ := f.Open()
b, _ := io.ReadAll(rc)
rc.Close()
if !strings.Contains(string(b), "你好") {
t.Fatalf("backup entry %q missing CJK body", f.Name)
}
}
// The duplicate title must have been disambiguated, not overwritten.
if !names["日记 Diary.md"] || !names["日记 Diary (1).md"] {
t.Fatalf("expected de-duplicated filenames, got %v", names)
}
}
func TestExportHTML(t *testing.T) { func TestExportHTML(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
id := seedRichDoc(t, srv) id := seedRichDoc(t, srv)
@@ -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)", "![a cat](/api/images/abc123.png)", "| 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) { func TestExportUnsupportedFormat(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
id := newDoc(t, srv) id := newDoc(t, srv)

132
internal/images/handler.go Normal file
View 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")
}

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

View File

@@ -27,3 +27,12 @@ var synonymsGz []byte
// //
//go:embed data/gloss.json.gz //go:embed data/gloss.json.gz
var glossGz []byte var glossGz []byte
// phoneticGz is the gzipped English-phonetic map: word → IPA, lowercase keys.
// Built from ECDICT's phonetic column (scripts/build_phonetic.py). Shown beside
// the read-aloud button so the writer — a Mandarin speaker learning English —
// can see how to say the word. Ships with a small common-word seed; a full build
// broadens coverage.
//
//go:embed data/phonetic.json.gz
var phoneticGz []byte

Binary file not shown.

View File

@@ -26,6 +26,7 @@ type Meaning struct {
type Result struct { type Result struct {
Word string `json:"word"` Word string `json:"word"`
Gloss string `json:"gloss"` Gloss string `json:"gloss"`
Phonetic string `json:"phonetic"` // IPA for the English word; "" when absent
Definitions []Meaning `json:"definitions"` Definitions []Meaning `json:"definitions"`
Synonyms []string `json:"synonyms"` Synonyms []string `json:"synonyms"`
} }
@@ -56,6 +57,7 @@ type Lexicon struct {
defs map[string][][]string // word → [[pos, def, example], …] defs map[string][][]string // word → [[pos, def, example], …]
synonyms map[string][]string // word → [synonym, …] synonyms map[string][]string // word → [synonym, …]
gloss map[string]string // word → Chinese gloss gloss map[string]string // word → Chinese gloss
phonetic map[string]string // word → IPA
} }
// New returns a Lexicon. The datasets aren't read until the first Lookup. // New returns a Lexicon. The datasets aren't read until the first Lookup.
@@ -75,6 +77,10 @@ func (l *Lexicon) load() {
l.loadErr = fmt.Errorf("load gloss: %w", err) l.loadErr = fmt.Errorf("load gloss: %w", err)
return return
} }
if err := gunzipJSON(phoneticGz, &l.phonetic); err != nil {
l.loadErr = fmt.Errorf("load phonetic: %w", err)
return
}
}) })
} }
@@ -95,6 +101,9 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
} }
res.Gloss = lookupGloss(l.gloss, norm) res.Gloss = lookupGloss(l.gloss, norm)
// Phonetic is a word→string map like the gloss, so the same de-inflecting
// candidate walk resolves "running" → "run", etc.
res.Phonetic = lookupGloss(l.phonetic, norm)
if raw := lookupDefs(l.defs, norm); raw != nil { if raw := lookupDefs(l.defs, norm); raw != nil {
for _, m := range raw { for _, m := range raw {

101
scripts/build_phonetic.py Normal file
View 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": "ˈloʊ", "world": "ːrld", "people": "ˈpiːpl", "water": "ˈː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ɪ", "english": "ˈɪŋɡlɪʃ", "write": "raɪt", "writing": "ˈraɪtɪŋ",
"read": "riːd", "word": "ː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ɪˈːz", "people's": "ˈpiːplz", "important": "ɪmˈːrtnt",
"different": "ˈdɪfərənt", "remember": "rɪˈmɛmbər", "together": "ˈɡɛðər",
"morning": "ˈːrnɪŋ", "tonight": "ˈnaɪt", "yesterday": "ˈjɛstərdeɪ",
"tomorrow": "ˈ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": "ɪ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()

165
scripts/gen_sounds.py Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Generate Petal's cute UI sound palette as small WAV assets.
These are warm, rounded little sounds — bubble pops, water drops, soft bells —
played when suggestions and companion bubbles appear. We synthesize them here
(rather than ship someone else's clips) so they match the app's gentle aesthetic
exactly and stay tiny. Output lands in web/src/assets/sounds/ where Vite bundles
and fingerprints them into dist (and so into the embedded Go binary).
Run: python3 scripts/gen_sounds.py
"""
import math
import os
import struct
import wave
SR = 44100
OUT = os.path.join(os.path.dirname(__file__), "..", "web", "src", "assets", "sounds")
def env(n, attack, decay, total):
"""Smooth attack + exponential decay envelope over `total` seconds."""
a = max(1, int(attack * SR))
out = []
for i in range(n):
t = i / SR
if i < a:
amp = i / a
else:
amp = math.exp(-(t - attack) / decay)
out.append(amp)
return out
def sine(freq_at, n):
"""Sine with a per-sample frequency function freq_at(t)->Hz (phase-accurate)."""
out = []
phase = 0.0
for i in range(n):
t = i / SR
f = freq_at(t)
phase += 2 * math.pi * f / SR
out.append(math.sin(phase))
return out
def mix(*layers):
n = max(len(l) for l in layers)
out = [0.0] * n
for l in layers:
for i, v in enumerate(l):
out[i] += v
return out
def soft_clip(s):
return [math.tanh(v * 1.2) for v in s]
def normalize(s, peak=0.9):
m = max(1e-9, max(abs(v) for v in s))
g = peak / m
return [v * g for v in s]
def write_wav(name, samples):
samples = soft_clip(samples)
samples = normalize(samples, 0.85)
# 4ms fade-out tail so nothing clicks at the end.
tail = int(0.004 * SR)
for i in range(tail):
samples[-1 - i] *= i / tail
os.makedirs(OUT, exist_ok=True)
path = os.path.join(OUT, name)
with wave.open(path, "w") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
frames = b"".join(struct.pack("<h", int(max(-1, min(1, v)) * 32767)) for v in samples)
w.writeframes(frames)
print(f"wrote {name} ({len(samples)/SR*1000:.0f} ms, {len(frames)} bytes)")
def apply_env(sig, e):
return [s * a for s, a in zip(sig, e)]
def bubble_pop():
"""A cute bubble 'bloop' — pitch swoops up fast then a rounded body."""
dur = 0.16
n = int(dur * SR)
# Rising swoop is the signature of a bubble.
body = sine(lambda t: 360 + 720 * (1 - math.exp(-t * 38)), n)
body = apply_env(body, env(n, 0.004, 0.05, dur))
# A soft octave shimmer on top.
shimmer = sine(lambda t: 1080 + 400 * (1 - math.exp(-t * 38)), n)
shimmer = apply_env(shimmer, env(n, 0.003, 0.03, dur))
shimmer = [v * 0.25 for v in shimmer]
return mix(body, shimmer)
def droplet():
"""A clean little water-drop ping — high, downward, watery tail."""
dur = 0.22
n = int(dur * SR)
main = sine(lambda t: 1500 * math.exp(-t * 3.2) + 760, n)
main = apply_env(main, env(n, 0.003, 0.06, dur))
# Tiny resonant echo for a wet feel.
echo = sine(lambda t: 980, n)
echo = apply_env(echo, env(n, 0.05, 0.07, dur))
echo = [v * 0.2 for v in echo]
return mix(main, droop_delay(echo, 0.04))
def droop_delay(sig, delay_s):
d = int(delay_s * SR)
return [0.0] * d + sig
def bell(freq, dur, partials=((1, 1.0), (2.01, 0.5), (2.78, 0.28), (4.1, 0.12))):
"""A soft inharmonic bell tone (sum of slightly detuned partials)."""
n = int(dur * SR)
layers = []
for ratio, amp in partials:
s = sine(lambda t, f=freq * ratio: f, n)
s = apply_env(s, env(n, 0.005, dur * 0.45, dur))
layers.append([v * amp for v in s])
return mix(*layers)
def chime():
"""Two gentle bell notes a soft fifth apart."""
a = bell(784, 0.42) # G5
b = bell(1175, 0.40) # D6
b = droop_delay(b, 0.10)
return mix(a, [v * 0.85 for v in b])
def shimmer():
"""Three quick ascending sparkles — glockenspiel-ish."""
notes = [988, 1319, 1976] # B5, E6, B6
layers = []
for i, f in enumerate(notes):
s = bell(f, 0.30, partials=((1, 1.0), (2.7, 0.3), (5.1, 0.1)))
layers.append(droop_delay([v * 0.8 for v in s], 0.06 * i))
return mix(*layers)
def cheer():
"""A happy little major arpeggio C5-E5-G5-C6 on bells."""
notes = [523, 659, 784, 1047]
layers = []
for i, f in enumerate(notes):
s = bell(f, 0.36)
layers.append(droop_delay([v * 0.8 for v in s], 0.075 * i))
return mix(*layers)
if __name__ == "__main__":
write_wav("pop.wav", bubble_pop())
write_wav("droplet.wav", droplet())
write_wav("chime.wav", chime())
write_wav("shimmer.wav", shimmer())
write_wav("cheer.wav", cheer())
print("done →", os.path.normpath(OUT))

124
web/package-lock.json generated
View File

@@ -9,7 +9,15 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tiptap/extension-character-count": "^2.11.5", "@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-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-text-align": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5", "@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5", "@tiptap/pm": "^2.11.5",
@@ -1568,6 +1576,20 @@
"@tiptap/pm": "^2.7.0" "@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": { "node_modules/@tiptap/extension-document": {
"version": "2.27.2", "version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
@@ -1652,6 +1674,19 @@
"@tiptap/core": "^2.7.0" "@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": { "node_modules/@tiptap/extension-history": {
"version": "2.27.2", "version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
@@ -1680,6 +1715,19 @@
"@tiptap/pm": "^2.7.0" "@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": { "node_modules/@tiptap/extension-italic": {
"version": "2.27.2", "version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
@@ -1693,6 +1741,23 @@
"@tiptap/core": "^2.7.0" "@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": { "node_modules/@tiptap/extension-list-item": {
"version": "2.27.2", "version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", "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" "@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": { "node_modules/@tiptap/extension-text": {
"version": "2.27.2", "version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
@@ -2828,6 +2946,12 @@
"uc.micro": "^2.0.0" "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": { "node_modules/lottie-web": {
"version": "5.13.0", "version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",

View File

@@ -12,7 +12,15 @@
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-character-count": "^2.11.5", "@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-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-text-align": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5", "@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5", "@tiptap/pm": "^2.11.5",

View File

@@ -13,6 +13,8 @@ import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion' import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner' import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
import { useVersionWatch } from './hooks/useVersionWatch' import { useVersionWatch } from './hooks/useVersionWatch'
import { PetalFall } from './effects/PetalFall'
import { playSuggestionSound } from './audio/sounds'
export default function App() { export default function App() {
const updateAvailable = useVersionWatch() const updateAvailable = useVersionWatch()
@@ -190,6 +192,38 @@ export default function App() {
setDocText('') setDocText('')
}, [saveNow, isBlankDraft]) }, [saveNow, isBlankDraft])
// Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)",
// then open the copy. Tags aren't carried over (a fresh start for the copy).
const handleDuplicate = useCallback(
async (id: string) => {
try {
await saveNow() // flush in case we're duplicating the open doc
const src = await api.getDoc(id)
const fresh = await api.createDoc()
const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)`
const updated = await api.updateDoc(fresh.id, {
title: dupTitle,
content: src.content,
content_text: src.content_text,
tone: src.tone,
word_count: src.word_count,
})
setDocs((prev) => [
{ id: updated.id, title: updated.title, word_count: updated.word_count, updated_at: updated.updated_at, tags: [] },
...prev,
])
setCurrentDoc(updated)
setTitle(updated.title)
setWordCount(updated.word_count)
setTone(updated.tone || 'general')
setDocText(updated.content_text)
} catch (err) {
console.error('duplicate failed', err)
}
},
[saveNow],
)
const handleDelete = useCallback( const handleDelete = useCallback(
async (id: string) => { async (id: string) => {
await api.deleteDoc(id) await api.deleteDoc(id)
@@ -309,8 +343,34 @@ export default function App() {
[removeSuggestion], [removeSuggestion],
) )
// Play a soft sound when freshly-checked suggestions arrive — one per distinct
// new type, lightly staggered so a batch reads as a little melody rather than
// a pile-up. We track which ids we've already chimed for, and only chime for
// recently-created suggestions so opening a doc with old pending advice stays
// silent (the existing set was created in a past session).
const chimedRef = useRef<Set<string>>(new Set())
useEffect(() => {
const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id))
fresh.forEach((s) => chimedRef.current.add(s.id))
const justMade = fresh.filter(
(s) => Date.now() - new Date(s.created_at).getTime() < 12_000,
)
if (justMade.length === 0) return
const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3)
const timers = types.map((t, i) =>
window.setTimeout(() => playSuggestionSound(t), i * 150),
)
return () => timers.forEach((id) => window.clearTimeout(id))
}, [suggestions])
// Forget chimed ids when switching documents so the set can't grow unbounded.
useEffect(() => {
chimedRef.current = new Set()
}, [currentDoc?.id])
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PetalFall />
<header <header
onMouseDown={handleChromeDown} onMouseDown={handleChromeDown}
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5" className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
@@ -343,6 +403,7 @@ export default function App() {
onSelect={openDoc} onSelect={openDoc}
onCreate={handleCreate} onCreate={handleCreate}
onDelete={handleDelete} onDelete={handleDelete}
onDuplicate={handleDuplicate}
onToggleTag={handleToggleTag} onToggleTag={handleToggleTag}
onCreateTag={handleCreateTag} onCreateTag={handleCreateTag}
/> />
@@ -447,6 +508,7 @@ export default function App() {
<PetalCompanion <PetalCompanion
wordCount={wordCount} wordCount={wordCount}
saveStatus={status} saveStatus={status}
llmDown={llmDown}
editTick={editTick} editTick={editTick}
acceptTick={acceptTick} acceptTick={acceptTick}
text={docText} text={docText}

View File

@@ -65,6 +65,7 @@ export interface WordMeaning {
export interface WordInfo { export interface WordInfo {
word: string word: string
gloss: string // Chinese translation; '' when the word isn't in the gloss set gloss: string // Chinese translation; '' when the word isn't in the gloss set
phonetic: string // IPA for the English word; '' when absent
definitions: WordMeaning[] definitions: WordMeaning[]
synonyms: string[] synonyms: string[]
} }
@@ -164,6 +165,10 @@ export const api = {
exportUrl: (id: string, format: ExportFormat) => exportUrl: (id: string, format: ExportFormat) =>
`/api/docs/${id}/export?format=${format}`, `/api/docs/${id}/export?format=${format}`,
// Download URL for a whole-corpus backup: a zip of every document rendered in
// the given format. A one-click "download all my writing" safety net.
exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`,
// Offline word lookup (gloss + definition + synonyms) for the right-click popover. // Offline word lookup (gloss + definition + synonyms) for the right-click popover.
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`), lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant // Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
@@ -182,6 +187,21 @@ export const api = {
// highlighted snippet. Empty query returns []. Encodes the term for the URL. // highlighted snippet. Empty query returns []. Encodes the term for the URL.
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`), 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 // Tags. listTags returns the roster with per-tag document counts; createTag is
// idempotent on name (returns the existing tag if it already exists); // idempotent on name (returns the existing tag if it already exists);
// updateTag recolors/renames; deleteTag removes it (assignments cascade). // updateTag recolors/renames; deleteTag removes it (assignments cascade).

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

194
web/src/audio/sounds.ts Normal file
View File

@@ -0,0 +1,194 @@
// 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 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'
// Master volume — deliberately gentle. These are background delights, not alerts.
const MASTER_GAIN = 0.5
export type SoundName =
| '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> = {
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>()
let enabled = readEnabled()
const listeners = new Set<(on: boolean) => void>()
function readEnabled(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== 'off'
} catch {
return true
}
}
export function isSoundEnabled(): boolean {
return enabled
}
export function setSoundEnabled(on: boolean): void {
enabled = on
try {
localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
} catch {
/* private mode — choice just won't persist */
}
listeners.forEach((fn) => fn(on))
// Touching the context on enable doubles as a user-gesture unlock + warm-up.
if (on) void ensureContext()
}
export function onSoundEnabledChange(fn: (on: boolean) => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
// Lazily create the AudioContext (browsers require a user gesture before audio
// can play; the first play() after a click will succeed, earlier ones resume).
async function ensureContext(): Promise<AudioContext | null> {
if (typeof window === 'undefined') return null
const AC: typeof AudioContext | undefined =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AC) return null
if (!ctx) {
ctx = new AC()
master = ctx.createGain()
master.gain.value = MASTER_GAIN
// A gentle soft-clip so stacked sounds never crackle.
const shaper = ctx.createWaveShaper()
shaper.curve = softClipCurve()
master.connect(shaper)
shaper.connect(ctx.destination)
}
if (ctx.state === 'suspended') {
try {
await ctx.resume()
} catch {
/* will retry on the next gesture */
}
}
return ctx
}
function softClipCurve(): Float32Array<ArrayBuffer> {
const n = 1024
const curve = new Float32Array(new ArrayBuffer(n * 4))
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * 2 - 1
curve[i] = Math.tanh(x * 1.2)
}
return curve
}
// Fetch + decode one sound, caching the decoded buffer for instant replay.
async function load(name: SoundName, audio: AudioContext): Promise<AudioBuffer | null> {
const cached = buffers.get(name)
if (cached) return cached
try {
const res = await fetch(SOURCES[name])
const bytes = await res.arrayBuffer()
const buf = await audio.decodeAudioData(bytes)
buffers.set(name, buf)
return buf
} catch {
return null // asset missing / decode failed — sound just won't play
}
}
// Warm the cache so the first real play is instant.
async function prime(): Promise<void> {
const audio = await ensureContext()
if (!audio) return
await Promise.all((Object.keys(SOURCES) as SoundName[]).map((n) => load(n, audio)))
}
// Fire a sound. No-op when muted or audio is unavailable. Never throws — a
// delight that fails should fail silently.
export function playSound(name: SoundName): void {
if (!enabled) return
void (async () => {
const audio = await ensureContext()
if (!audio || !master || audio.state !== 'running') return
const buf = await load(name, audio)
if (!buf) return
try {
const src = audio.createBufferSource()
src.buffer = buf
src.connect(master)
src.start()
} catch {
/* audio glitch — ignore */
}
})()
}
// 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])
}
// 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
// dropped by the browser's autoplay policy. Self-removing.
if (typeof window !== 'undefined') {
const unlock = () => {
void prime()
window.removeEventListener('pointerdown', unlock)
window.removeEventListener('keydown', unlock)
}
window.addEventListener('pointerdown', unlock)
window.addEventListener('keydown', unlock)
}

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

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

View File

@@ -7,6 +7,7 @@ import { COMPANIONS, DEFAULT_COMPANION } from './companions'
interface Props { interface Props {
wordCount: number wordCount: number
saveStatus: SaveStatus saveStatus: SaveStatus
llmDown: boolean
editTick: number editTick: number
acceptTick: number acceptTick: number
text: string text: string
@@ -27,10 +28,11 @@ const STORAGE_KEY = 'petal.companion'
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and // useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
// break reminders. Clicking the mascot opens a picker to switch companions // break reminders. Clicking the mascot opens a picker to switch companions
// (the choice persists in localStorage). // (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({ const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
wordCount, wordCount,
saveStatus, saveStatus,
llmDown,
editTick, editTick,
acceptTick, acceptTick,
text, text,

View File

@@ -48,8 +48,16 @@ export const WELCOME_BACK: Line = {
en: 'Welcome back ✨ lets keep going!', en: 'Welcome back ✨ lets keep going!',
} }
// Word-count milestones worth a little cheer. // Gentle "haiya, something went wrong" lines — paired with the error sound when
export const MILESTONES = [50, 100, 250, 500, 1000, 2000] // 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 { export function milestoneLine(n: number): Line {
return { return {

View File

@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
import { import {
BREAKS, BREAKS,
ENCOURAGEMENTS, ENCOURAGEMENTS,
ERRORS,
GREETING, GREETING,
MILESTONES, MILESTONES,
TIPS, TIPS,
@@ -12,12 +13,13 @@ import {
type Line, type Line,
} from './tips' } from './tips'
import { analyzeProse } from './prose' import { analyzeProse } from './prose'
import { playPop, playSound, type SoundName } from '../../audio/sounds'
// The kitten's expression. Maps to a Lottie animation when assets are present, // The kitten's expression. Maps to a Lottie animation when assets are present,
// otherwise to an emoji placeholder (see PetalCompanion). // otherwise to an emoji placeholder (see PetalCompanion).
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate' 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 { export interface Bubble extends Line {
tone: BubbleTone tone: BubbleTone
} }
@@ -25,6 +27,9 @@ export interface Bubble extends Line {
interface Signals { interface Signals {
wordCount: number wordCount: number
saveStatus: SaveStatus 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 // Monotonic counters bumped by the app on each editor change / accepted
// suggestion — lets the companion react without a full event bus. // suggestion — lets the companion react without a full event bus.
editTick: number editTick: number
@@ -61,7 +66,7 @@ function readBubbleMs(b: Bubble): number {
// useCompanion is the behavior engine: it watches writing signals and decides // 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 // 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. // 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 [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(null) const [bubble, setBubble] = useState<Bubble | null>(null)
@@ -87,7 +92,8 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
// Show a bubble + talking mood, then settle back. `proactive` messages respect // Show a bubble + talking mood, then settle back. `proactive` messages respect
// the spacing floor; user-triggered ones (cheers) always go through. // 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() const t = now()
if (opts?.proactive) { if (opts?.proactive) {
if (bubble) return if (bubble) return
@@ -95,6 +101,11 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
lastProactive.current = t lastProactive.current = t
} }
sleeping.current = false sleeping.current = false
// 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) setBubble(b)
setMood(opts?.celebrate ? 'celebrate' : 'talking') setMood(opts?.celebrate ? 'celebrate' : 'talking')
clearTimeout(bubbleTimer.current) clearTimeout(bubbleTimer.current)
@@ -190,11 +201,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
const n = MILESTONES[nextMilestone.current] const n = MILESTONES[nextMilestone.current]
nextMilestone.current += 1 nextMilestone.current += 1
// Skip silently if we're just loading a long doc (no edits yet). // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordCount]) }, [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). // Occasional gentle cheer on a successful save (kept rare so it isn't noise).
useEffect(() => { useEffect(() => {
if (saveStatus === 'saved' && Math.random() < 0.18) { if (saveStatus === 'saved' && Math.random() < 0.18) {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,238 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Editor } from '@tiptap/react'
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
// SearchHighlight decoration layer: typing updates the highlighted matches, the
// arrows step through them (scrolling each into view), and replace / replace-all
// edit the document in place. Bilingual, zh-first, matching the editor chrome.
interface Props {
editor: Editor
onClose: () => void
}
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
// Scroll the active match into the middle of the viewport without moving the
// selection (which would otherwise pop the rewrite bubble).
function scrollToActive(editor: Editor) {
const st = getSearchState(editor.state)
if (st.active < 0) return
const m = st.matches[st.active]
const dom = editor.view.domAtPos(m.from)
const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : (dom.node as HTMLElement)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
export function FindReplace({ editor, onClose }: Props) {
const [query, setQuery] = useState('')
const [replacement, setReplacement] = useState('')
const [caseSensitive, setCaseSensitive] = useState(false)
const [showReplace, setShowReplace] = useState(false)
// Mirror of the plugin's match count + active index for the "n / m" readout.
const [{ count, active }, setCounts] = useState({ count: 0, active: -1 })
const findRef = useRef<HTMLInputElement>(null)
const sync = useCallback(() => {
const st = getSearchState(editor.state)
setCounts({ count: st.matches.length, active: st.active })
}, [editor])
// Push the query into the decoration layer whenever it (or case-sensitivity)
// changes, then jump to the first match.
useEffect(() => {
setSearch(editor.state, editor.view.dispatch, query, caseSensitive)
sync()
scrollToActive(editor)
}, [editor, query, caseSensitive, sync])
// Tear the highlight layer down when the bar closes.
useEffect(() => () => clearSearch(editor.state, editor.view.dispatch), [editor])
// Focus the find field on open.
useEffect(() => {
findRef.current?.focus()
findRef.current?.select()
}, [])
const go = useCallback(
(delta: number) => {
const st = getSearchState(editor.state)
if (!st.matches.length) return
setActive(editor.state, editor.view.dispatch, st.active + delta)
sync()
scrollToActive(editor)
},
[editor, sync],
)
const replaceActive = useCallback(() => {
const st = getSearchState(editor.state)
if (st.active < 0) return
const m = st.matches[st.active]
const tr = editor.state.tr
if (replacement) tr.insertText(replacement, m.from, m.to)
else tr.delete(m.from, m.to)
editor.view.dispatch(tr)
sync()
scrollToActive(editor)
}, [editor, replacement, sync])
const replaceAll = useCallback(() => {
const st = getSearchState(editor.state)
if (!st.matches.length) return
const tr = editor.state.tr
// Apply back-to-front so earlier edits don't shift later match positions.
for (let i = st.matches.length - 1; i >= 0; i--) {
const m = st.matches[i]
if (replacement) tr.insertText(replacement, m.from, m.to)
else tr.delete(m.from, m.to)
}
editor.view.dispatch(tr)
sync()
}, [editor, replacement, sync])
const inputStyle: React.CSSProperties = {
borderRadius: 'var(--radius-input)',
border: '1px solid var(--color-border)',
color: 'var(--color-plum)',
fontFamily: CJK,
}
return (
<div
className="petal-no-print absolute right-2 top-2 z-40 flex flex-col gap-1.5 p-2"
role="dialog"
aria-label="Find and replace"
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}}
style={{
width: 320,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
}}
>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setShowReplace((v) => !v)}
aria-label={showReplace ? 'Hide replace' : 'Show replace'}
title={showReplace ? 'Hide replace' : 'Show replace'}
className="flex h-8 w-6 items-center justify-center text-xs"
style={{ color: 'var(--color-muted)' }}
>
{showReplace ? '▾' : '▸'}
</button>
<input
ref={findRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
go(e.shiftKey ? -1 : 1)
}
}}
placeholder="查找 · Find"
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
style={inputStyle}
/>
<span
className="w-14 shrink-0 text-center text-xs tabular-nums"
style={{ color: 'var(--color-muted)' }}
>
{count ? `${active + 1} / ${count}` : query ? '无 · 0' : ''}
</span>
<FindBtn label="Previous match" disabled={!count} onClick={() => go(-1)}></FindBtn>
<FindBtn label="Next match" disabled={!count} onClick={() => go(1)}></FindBtn>
<FindBtn
label="Match case"
active={caseSensitive}
onClick={() => setCaseSensitive((v) => !v)}
title="Match case · 区分大小写"
>
Aa
</FindBtn>
<FindBtn label="Close" onClick={onClose} title="Close · 关闭"></FindBtn>
</div>
{showReplace && (
<div className="flex items-center gap-1.5 pl-7">
<input
value={replacement}
onChange={(e) => setReplacement(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
replaceActive()
}
}}
placeholder="替换为 · Replace"
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
style={inputStyle}
/>
<button
type="button"
onClick={replaceActive}
disabled={!count}
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-semibold disabled:opacity-40"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
</button>
<button
type="button"
onClick={replaceAll}
disabled={!count}
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-bold disabled:opacity-40"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: '#fff' }}
>
</button>
</div>
)}
</div>
)
}
function FindBtn({
children,
onClick,
label,
title,
active,
disabled,
}: {
children: React.ReactNode
onClick: () => void
label: string
title?: string
active?: boolean
disabled?: boolean
}) {
return (
<button
type="button"
aria-label={label}
title={title ?? label}
aria-pressed={active}
disabled={disabled}
onClick={onClick}
className="flex h-8 min-w-8 items-center justify-center px-1.5 text-sm font-semibold disabled:opacity-40"
style={{
borderRadius: 'var(--radius-input)',
color: active ? 'var(--color-accent-hover)' : 'var(--color-muted)',
background: active ? 'var(--color-surface-alt)' : 'transparent',
}}
>
{children}
</button>
)
}

View File

@@ -0,0 +1,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(),
}
},
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
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
// when sounds are turned back on so the choice is audible.
export function SoundToggle() {
const [on, setOn] = useState(isSoundEnabled)
// Stay in sync if the setting is flipped elsewhere.
useEffect(() => onSoundEnabledChange(setOn), [])
const toggle = () => {
const next = !on
setSoundEnabled(next)
setOn(next)
if (next) playPop() // a little hello when re-enabled
}
return (
<button
type="button"
onClick={toggle}
aria-pressed={on}
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
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) =>
(e.currentTarget.style.color = on ? 'var(--color-accent)' : 'var(--color-muted)')
}
>
<span aria-hidden>{on ? '🔔' : '🔕'}</span>
</button>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave' import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel' import { StatsPanel } from './StatsPanel'
import { SoundToggle } from './SoundToggle'
interface Props { interface Props {
wordCount: number wordCount: number
@@ -44,7 +45,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
return ( return (
<footer <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)' }} style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
> >
<div className="relative" ref={statsRef}> <div className="relative" ref={statsRef}>
@@ -53,7 +54,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
onClick={() => setStatsOpen((o) => !o)} onClick={() => setStatsOpen((o) => !o)}
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={statsOpen} 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' }} style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')} onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
onMouseLeave={(e) => onMouseLeave={(e) =>
@@ -120,6 +121,9 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
</span> </span>
</> </>
)} )}
<div className="ml-auto">
<SoundToggle />
</div>
</footer> </footer>
) )
} }

View File

@@ -1,5 +1,7 @@
import type { Editor } from '@tiptap/react' import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react' import { useEditorState } from '@tiptap/react'
import { useEffect, useRef, useState } from 'react'
import { uploadImageInto } from '../Editor/EditorCore'
interface Props { interface Props {
editor: Editor | null editor: Editor | null
@@ -15,18 +17,21 @@ function TBtn({
active, active,
disabled, disabled,
label, label,
title,
children, children,
}: { }: {
onClick: () => void onClick: () => void
active?: boolean active?: boolean
disabled?: boolean disabled?: boolean
label: string label: string
title?: string
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<button <button
type="button" type="button"
aria-label={label} aria-label={label}
title={title ?? label}
aria-pressed={active} aria-pressed={active}
disabled={disabled} disabled={disabled}
onMouseDown={(e) => e.preventDefault()} // keep editor selection 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)' }} /> <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. // Toolbar renders inline formatting controls bound to the live Tiptap editor.
// useEditorState subscribes to just the flags it reads, so the buttons reflect // useEditorState subscribes to just the flags it reads, so the buttons reflect
// the current selection without re-rendering the whole tree on every keystroke. // the current selection without re-rendering the whole tree on every keystroke.
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) { export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
// Which popover (if any) is open. Only one at a time.
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const state = useEditorState({ const state = useEditorState({
editor, editor,
selector: ({ editor }) => selector: ({ editor }) =>
@@ -59,27 +179,92 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
bold: editor.isActive('bold'), bold: editor.isActive('bold'),
italic: editor.isActive('italic'), italic: editor.isActive('italic'),
underline: editor.isActive('underline'), underline: editor.isActive('underline'),
strike: editor.isActive('strike'),
h1: editor.isActive('heading', { level: 1 }), h1: editor.isActive('heading', { level: 1 }),
h2: editor.isActive('heading', { level: 2 }), h2: editor.isActive('heading', { level: 2 }),
h3: editor.isActive('heading', { level: 3 }),
bullet: editor.isActive('bulletList'), bullet: editor.isActive('bulletList'),
ordered: editor.isActive('orderedList'),
left: editor.isActive({ textAlign: 'left' }), left: editor.isActive({ textAlign: 'left' }),
center: editor.isActive({ textAlign: 'center' }), center: editor.isActive({ textAlign: 'center' }),
right: editor.isActive({ textAlign: 'right' }), 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, : null,
}) })
if (!editor || !state) return 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 ( return (
<div <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={{ style={{
borderRadius: 'var(--radius-pill)', borderRadius: 'var(--radius-card)',
background: 'var(--color-surface)', background: 'var(--color-surface)',
boxShadow: 'var(--shadow-soft)', 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()}> <TBtn label="Bold" active={state.bold} onClick={() => editor.chain().focus().toggleBold().run()}>
<span className="font-extrabold">B</span> <span className="font-extrabold">B</span>
</TBtn> </TBtn>
@@ -93,51 +278,301 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
> >
<span className="underline">U</span> <span className="underline">U</span>
</TBtn> </TBtn>
<TBtn label="Strikethrough" active={state.strike} onClick={() => editor.chain().focus().toggleStrike().run()}>
<span className="line-through">S</span>
</TBtn>
<Divider /> <Divider />
<TBtn
label="Heading 1" {/* Text color */}
active={state.h1} <Popover
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} 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 H1
</TBtn> </TBtn>
<TBtn <TBtn label="Heading 2" active={state.h2} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
label="Heading 2"
active={state.h2}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2 H2
</TBtn> </TBtn>
<TBtn <TBtn label="Heading 3" active={state.h3} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
label="Bullet list" H3
active={state.bullet} </TBtn>
onClick={() => editor.chain().focus().toggleBulletList().run()} <TBtn label="Bullet list" active={state.bullet} onClick={() => editor.chain().focus().toggleBulletList().run()}>
>
</TBtn> </TBtn>
<TBtn label="Numbered list" active={state.ordered} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
1.
</TBtn>
<Divider /> <Divider />
<TBtn
label="Align left" <TBtn label="Align left" active={state.left} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
active={state.left}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
>
</TBtn> </TBtn>
<TBtn <TBtn label="Align center" active={state.center} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
label="Align center"
active={state.center}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
>
</TBtn> </TBtn>
<TBtn <TBtn label="Align right" active={state.right} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
label="Align right"
active={state.right}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
>
</TBtn> </TBtn>
<Divider /> <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 <button
type="button" type="button"
aria-label="Check my voice" aria-label="Check my voice"
@@ -162,3 +597,58 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
</div> </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>
)
}

View File

@@ -0,0 +1,200 @@
import { useEffect, useRef } from 'react'
// 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.
// 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 SPRITE_PX = 160 // off-screen render resolution per petal sprite
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})`
}
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 }
}
// 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)
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
}
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() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const reduce =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) return // respect users who don't want ambient motion
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 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() {
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
let last = performance.now()
function frame(now: number) {
if (!running) return
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)
}
const onVisibility = () => {
if (document.hidden) {
running = false
cancelAnimationFrame(raf)
} else if (!running) {
running = true
last = performance.now()
raf = requestAnimationFrame(frame)
}
}
document.addEventListener('visibilitychange', onVisibility)
raf = requestAnimationFrame(frame)
return () => {
running = false
cancelAnimationFrame(raf)
window.removeEventListener('resize', resize)
document.removeEventListener('visibilitychange', onVisibility)
petals = []
}
}, [])
return (
<canvas
ref={canvasRef}
aria-hidden
className="petal-fall pointer-events-none fixed inset-0"
style={{ zIndex: 20 }}
/>
)
}

View File

@@ -98,6 +98,93 @@ button, a, input {
padding: 0.1em 0.35em; padding: 0.1em 0.35em;
border-radius: 6px; 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). */ /* Placeholder shown on the empty first paragraph (Tiptap Placeholder ext). */
.petal-prose p.is-editor-empty:first-child::before { .petal-prose p.is-editor-empty:first-child::before {
content: attr(data-placeholder); content: attr(data-placeholder);
@@ -136,6 +223,20 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both; animation: petal-suggestion-in 200ms ease both;
} }
/* --- Find & Replace ---------------------------------------------------------
In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the
current match is brighter with a rose ring so it stands out as you step
through. Decorations, like every other highlight layer here. */
.petal-find-match {
background: var(--color-highlight, #fff1a8);
border-radius: 3px;
box-shadow: 0 0 0 1px var(--color-border);
}
.petal-find-match-active {
background: var(--color-peach);
box-shadow: 0 0 0 2px var(--color-accent);
}
/* --- Spell check ------------------------------------------------------------ /* --- Spell check ------------------------------------------------------------
Browser-side nspell flags misspellings with a soft rose wavy underline (a Browser-side nspell flags misspellings with a soft rose wavy underline (a
gentler take on the classic red squiggle, to fit the pastel palette). Like the gentler take on the classic red squiggle, to fit the pastel palette). Like the
@@ -382,7 +483,31 @@ button, a, input {
only the title and the writing itself reach the page. This is Petal's PDF only the title and the writing itself reach the page. This is Petal's PDF
path — it uses the browser's own fonts, so CJK renders correctly with no path — it uses the browser's own fonts, so CJK renders correctly with no
server-side font embedding. */ server-side font embedding. */
/* --- Ambient falling petals -------------------------------------------------
The WebGL petal layer is a fixed, non-interactive overlay that drifts soft
blossoms over the page. Kept gentle (low opacity) so it never competes with
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: 1;
}
/* Honor reduced-motion globally: still the looping ambient animations and drop
the petal layer. One-shot entrance/feedback animations are left intact. */
@media (prefers-reduced-motion: reduce) {
.petal-fall {
display: none !important;
}
.petal-bob,
.petal-bob-slow,
.petal-zzz,
.petal-checkpoint-dot {
animation: none !important;
}
}
@media print { @media print {
.petal-fall,
.petal-no-print, .petal-no-print,
.petal-sidebar, .petal-sidebar,
.petal-suggestion-card, .petal-suggestion-card,

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />