10 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
prosolis
69ecb79da1 Companion: rules-based, context-aware writing advice
Replace the kitten's random generic tips with a deterministic, LLM-free
prose checker that reads the actual document and surfaces one gentle,
bilingual note at a time — quoting a slice of her own sentence so the
advice is clearly about *this* draft.

Rules (all conservative, tuned for a Mandarin-native ESL writer):
run-ons, comma splices, unclear antecedents, Oxford comma, a/an,
uncountable plurals, capitalize languages/days/months, subject-verb
agreement, plural-after-number, double determiner, "there is" + plural,
its/it's, than/then, comma-after-transition, doubled words, lowercase
"i", sentence capitals, and spacing around punctuation.

Each rule is paired with false-positive guards (e.g. "a university",
"After dinner, I went home and read", existing Oxford lists) and pinned
by a 45-case Vitest suite (npm test).

Also: bubbles now linger proportional to how much there is to read
(zh+en) and pause-on-hover, so denser advice no longer vanishes mid-read.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:16:23 -07:00
prosolis
f3c4fe2c22 Merge Phase 10: organization & polish (search, tags, touch, warm failures)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 06:30:21 -07:00
46 changed files with 4326 additions and 74 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))

488
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",
@@ -28,7 +36,8 @@
"dictionary-en": "^4.0.0", "dictionary-en": "^4.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.1.0" "vite": "^6.1.0",
"vitest": "^4.1.9"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1178,6 +1187,13 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
@@ -1560,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",
@@ -1644,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",
@@ -1672,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",
@@ -1685,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",
@@ -1751,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",
@@ -1934,6 +2060,24 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2010,12 +2154,135 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.40", "version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -2084,6 +2351,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2176,6 +2453,13 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2240,6 +2524,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2642,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",
@@ -2746,12 +3056,33 @@
"is-buffer": "^2.0.0" "is-buffer": "^2.0.0"
} }
}, },
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/orderedmap": { "node_modules/orderedmap": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3103,6 +3434,13 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3113,6 +3451,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
@@ -3134,6 +3486,23 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3151,6 +3520,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tippy.js": { "node_modules/tippy.js": {
"version": "6.3.7", "version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
@@ -3295,12 +3674,119 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/w3c-keyname": { "node_modules/w3c-keyname": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -6,11 +6,21 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"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",
@@ -29,6 +39,7 @@
"dictionary-en": "^4.0.0", "dictionary-en": "^4.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.1.0" "vite": "^6.1.0",
"vitest": "^4.1.9"
} }
} }

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,8 +508,10 @@ 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}
/> />
</div> </div>
</div> </div>

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,8 +7,10 @@ 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
} }
// Emoji placeholder per mood, used for any mood a companion has no Lottie for. // Emoji placeholder per mood, used for any mood a companion has no Lottie for.
@@ -26,8 +28,15 @@ 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 }: Props) { export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick }) const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
wordCount,
saveStatus,
llmDown,
editTick,
acceptTick,
text,
})
const [companionId, setCompanionId] = useState<string>(() => { const [companionId, setCompanionId] = useState<string>(() => {
try { try {
@@ -136,6 +145,8 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
<div <div
role="status" role="status"
onClick={dismiss} onClick={dismiss}
onMouseEnter={holdBubble}
onMouseLeave={releaseBubble}
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5" className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
style={{ style={{
background: 'var(--color-surface)', background: 'var(--color-surface)',

View File

@@ -0,0 +1,250 @@
import { describe, it, expect } from 'vitest'
import { analyzeProse } from './prose'
// These tests pin the rules-based prose checker's behavior. For an ESL writer a
// *wrong* nudge costs trust faster than a missed one earns it, so every rule is
// tested in two directions: a true-positive case that must fire, and at least
// one near-miss "guard" case that must stay silent. Sentences are padded past
// the 8-word floor that analyzeProse needs before it offers advice.
const rulesFor = (text: string) => analyzeProse(text).map((h) => h.rule)
// Assert a given rule appears among the hints for this text.
function expectRule(text: string, rule: string) {
expect(rulesFor(text), `expected "${rule}" for: ${text}`).toContain(rule)
}
// Assert the text produces no hints at all (false-positive guard).
function expectClean(text: string) {
expect(analyzeProse(text), `expected no hints for: ${text}`).toEqual([])
}
describe('analyzeProse — floor', () => {
it('stays quiet on very short drafts (under 8 English words)', () => {
expect(analyzeProse('he go now')).toEqual([])
})
it('returns hints highest-priority first', () => {
// This 36-word run-on also contains a lowercase "i"; run-on must lead.
const hints = analyzeProse(
'yesterday i walked all the way to the market and i bought apples and oranges and i saw my friend there and we talked and laughed and walked back home together in the warm afternoon sun.',
)
expect(hints[0].rule).toBe('runon')
expect(hints.map((h) => h.rule)).toContain('cap-i')
})
})
describe('run-ons', () => {
it('flags a long, comma-heavy sentence', () => {
expectRule(
'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.',
'runon',
)
})
it('leaves a normal sentence alone', () => {
expectClean('The garden was quiet in the morning and the rain fell softly on the leaves.')
})
})
describe('comma splices', () => {
it('flags two independent clauses joined by a comma', () => {
expectRule('I finished the report, I sent it to my boss right away today.', 'splice')
})
it('catches a splice with a verb not on any list', () => {
expectRule('The garden was quiet in the morning, she poured a cup of tea slowly.', 'splice')
})
it('does not flag an introductory transition (However, …)', () => {
expectClean('However, we decided to stay home and rest for the entire weekend.')
})
it('does not flag an introductory phrase (After dinner, …)', () => {
expectClean('After dinner, I went home and read a book until I fell asleep.')
})
it('does not flag a pronoun list after a comma', () => {
expectClean('We invited my brother, you and your sister to the dinner party.')
})
})
describe('unclear antecedents', () => {
it('flags a vague "This + verb" opener after a prior sentence', () => {
expectRule(
'We changed the whole schedule last week. This makes everyone confused about the new times.',
'antecedent',
)
})
it('leaves "This" alone when it names its noun', () => {
expectClean(
'We changed the whole schedule last week. This new schedule confuses everyone about the times.',
)
})
})
describe('oxford comma', () => {
it('flags a 3-item noun list missing the serial comma', () => {
expectRule('My mother, my father and my sister came to visit us last summer.', 'oxford')
})
it('flags a verb-phrase list missing the serial comma', () => {
expectRule('We cleaned the kitchen, washed the dishes and took out the trash.', 'oxford')
})
it('does not flag a list that already has the serial comma', () => {
expectClean('I bought apples, bananas, and oranges at the store this morning.')
})
it('does not flag a compound predicate as a list', () => {
expectClean('After dinner, I went home and read a book until I fell asleep.')
})
})
describe('a / an', () => {
it('flags "a" before a vowel sound', () => {
expectRule('She found a umbrella and a old book on the table by the window.', 'article')
})
it('flags "an" before a consonant sound', () => {
expectRule('He gave me an book and an pencil to use during the long exam.', 'article')
})
it('respects sound exceptions (a university, an hour)', () => {
expectClean('He is a university student and it took an hour to arrive here.')
})
})
describe('uncountable plurals', () => {
it('flags a pluralized mass noun', () => {
expectRule('She gave me many informations about the new job opening today.', 'uncountable')
})
it('leaves the singular mass noun alone', () => {
expectClean('He shared some useful advice and helped me with my homework tonight.')
})
})
describe('capitalization of proper nouns', () => {
it('flags lowercase languages and days', () => {
expectRule('I am learning english and french during my free time every week.', 'propercap')
expectRule('We will meet on monday to discuss the final project together soon.', 'propercap')
})
it('does not flag ambiguous homographs (may, march)', () => {
expectClean('They may visit in the spring and march through the old town square.')
})
it('leaves already-capitalized proper nouns alone', () => {
expectClean('I will study English and Chinese together this coming summer break here.')
})
})
describe('subjectverb agreement', () => {
it('flags a bare verb after he/she/it', () => {
expectRule('My brother is busy but he go to the gym every single morning.', 'sva')
expectRule('She have a lovely garden full of roses behind her small house.', 'sva')
})
it('flags a sentence-initial subject too', () => {
expectRule('It make me happy whenever the sun comes out after the rain.', 'sva')
})
it('does not flag causative/perception frames (let it go, make it work)', () => {
expectClean('Please let it go and try to enjoy the rest of your day.')
expectClean('We should make it work before the big meeting tomorrow afternoon.')
})
})
describe('plural after a number', () => {
it('flags a singular noun after a cardinal number', () => {
expectRule('I bought three apple and some bread at the market this morning.', 'plural')
})
it('does not flag an adjective between number and noun', () => {
expectClean('We adopted two small dogs from the shelter near our apartment today.')
})
it('does not flag irregular plurals (five people)', () => {
expectClean('There were five people waiting patiently outside in the cold morning rain.')
})
it('does not flag an already-plural noun', () => {
expectClean('She has two cats and they sleep all day long on the couch.')
})
})
describe('double determiner', () => {
it('flags a stacked article + possessive', () => {
expectRule('Please put the my book back on the shelf when you finish it.', 'doubledet')
})
})
describe('there is + plural', () => {
it('flags "there is" with a plural quantifier', () => {
expectRule('There is many reasons to visit the coast during the warm months.', 'thereis')
})
it('does not flag "there is some" with a mass noun', () => {
expectClean('There is some water left in the bottle if you are thirsty now.')
})
})
describe('its / its', () => {
it("flags it's used as a possessive", () => {
expectRule("The cat licked it's own paw while sitting quietly by the window.", 'its')
})
it('flags its used where "it is" is meant', () => {
expectRule('I think its a wonderful idea to travel together next year sometime.', 'its')
})
it('leaves correct possessive "its" alone', () => {
expectClean('The dog wagged its tail happily when we came home that evening.')
})
})
describe('than / then', () => {
it('flags a comparative followed by "then"', () => {
expectRule('This cake tastes much better then the one we made last weekend.', 'than')
})
it('leaves sequential "then" alone', () => {
expectClean('We ate dinner and then we watched a long movie on the sofa.')
})
})
describe('comma after a transition', () => {
it('flags a sentence-initial transition with no comma', () => {
expectRule('However we decided to stay home and rest for the whole weekend.', 'introcomma')
})
it('does not flag a sequence word (Then …)', () => {
expectClean('Then we walked to the park and fed the ducks by the pond.')
})
})
describe('spacing around punctuation', () => {
it('flags a missing space after a comma', () => {
expectRule('I like apples,bananas and grapes from the little shop down the street.', 'space-after')
})
it('flags a space before punctuation', () => {
expectRule('She smiled warmly , then walked away without saying a single word today.', 'space-punct')
})
it('does not flag thousands separators in numbers', () => {
expectClean('The list had 1,000 items and cost us about 2,500 dollars in total.')
})
})
describe('capitalization basics', () => {
it('flags a lowercase standalone "i"', () => {
expectRule('Yesterday i walked to the store and bought milk, eggs, and bread.', 'cap-i')
})
it('flags a sentence that does not start with a capital', () => {
expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence')
})
})

View File

@@ -0,0 +1,577 @@
// Rules-based prose checker — NO LLM. This is the companion's "smart" side: it
// reads the writer's actual text and surfaces one gentle, context-aware grammar
// or style note at a time, the way an attentive tutor leaning over her shoulder
// would. Every rule here is intentionally conservative: a wrong nudge erodes
// trust far faster than a missed one earns it, so we only speak up when a
// pattern is a high-confidence, genuinely-common English mistake.
//
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
// a short slice of her own sentence so the advice clearly belongs to *this*
// paragraph and not a generic tip jar.
import type { Line } from './tips'
export interface ProseHint extends Line {
// Stable, content-derived id so the same untouched sentence isn't re-flagged
// every cadence; the companion remembers recently-shown ids.
id: string
// Rule family, used to vary advice (don't show the same kind twice in a row).
rule: string
}
// ── small text helpers ──────────────────────────────────────────────────────
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['-][A-Za-z]+)*/g
// Split into sentence-ish chunks (text + leading punctuation trimmed). Mirrors
// the SENTENCE_RE terminators used elsewhere, including CJK fullwidth forms.
function sentences(text: string): string[] {
const out: string[] = []
const re = /[^.!?。!?]+[.!?。!?]*/g
let m: RegExpExecArray | null
while ((m = re.exec(text))) {
const s = m[0].trim()
if (s) out.push(s)
}
return out
}
// First few words of a sentence, as an anchor we can quote back to her.
function anchor(s: string, n = 6): string {
const words = s.split(/\s+/).slice(0, n).join(' ')
return words.length < s.length ? `${words}` : words
}
// Cheap, stable hash → short id suffix, so ids stay stable across recomputes but
// change the moment she edits the offending text.
function key(s: string): string {
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48)
}
// ── individual rules ────────────────────────────────────────────────────────
// Each rule pushes at most a couple of findings; the caller shows one at a time.
// Run-on / overly long sentences — the single most common readability problem
// for ESL writers, who often chain clauses that a period would serve better.
function runOns(text: string, out: ProseHint[]) {
let found = 0
for (const s of sentences(text)) {
if (found >= 2) break
const words = s.match(ENGLISH_WORD_RE)?.length ?? 0
const commas = (s.match(/,/g) ?? []).length
const longRun = words >= 32
const commaHeavy = words >= 24 && commas >= 2
if (longRun || commaHeavy) {
found++
out.push({
id: `runon:${key(s)}`,
rule: 'runon',
zh: '这句话有点长啦,分成两三句会更清楚 🌸',
en: `This one runs long — try splitting it: “${anchor(s)}`,
})
}
}
}
// Comma splice — two independent clauses joined by only a comma. We look for a
// comma immediately followed by a subject pronoun + a finite verb, which is a
// strong, low-false-positive signal of a spliced second clause.
// A comma, then a subject pronoun starting a fresh clause, then its verb. We no
// longer whitelist verbs (that lost too many real splices) — instead we take any
// word after the pronoun and reject the function words that signal it *isn't* a
// new clause (a conjunction/preposition list like “she and I”, “it of course”).
const SPLICE_RE = /[a-z]\w*,\s+(I|we|they|he|she|it|you)\s+([a-z]+)\b/g
const SPLICE_NONVERB = new Set([
'and', 'or', 'but', 'nor', 'yet', 'so', 'to', 'too', 'also', 'of', 'in', 'on',
'at', 'for', 'with', 'as', 'who', 'whom', 'which', 'that', 'than', 'then',
'will', // “…, I will, …” is usually an aside, not a clean splice
])
// First words that signal the lead is an *introductory* element (transition,
// subordinate clause, or adverbial phrase), where a following “, subject verb”
// is correct punctuation — not a splice. Guards against “However, we left.” and
// “After dinner, I went home.”
const INTRO_LEAD = new Set([
'after', 'before', 'when', 'while', 'although', 'though', 'because', 'since',
'if', 'as', 'until', 'unless', 'whenever', 'wherever', 'whereas', 'however',
'therefore', 'moreover', 'furthermore', 'nevertheless', 'nonetheless',
'meanwhile', 'consequently', 'otherwise', 'besides', 'instead', 'finally',
'first', 'firstly', 'second', 'secondly', 'next', 'then', 'later', 'eventually',
'afterwards', 'anyway', 'yesterday', 'today', 'tomorrow', 'honestly',
'actually', 'suddenly', 'sometimes', 'usually', 'often', 'also', 'still',
'now', 'here', 'there', 'well', 'yes', 'no', 'fortunately', 'unfortunately',
'surprisingly', 'clearly', 'obviously', 'basically', 'generally', 'initially',
'recently', 'currently', 'sadly', 'luckily', 'hopefully', 'frankly',
'personally', 'overall', 'soon', 'once', 'during', 'in', 'on', 'at', 'for',
'with', 'without', 'by', 'from', 'despite', 'throughout', 'given', 'regarding',
])
// A genuine comma splice has an *independent clause* before the comma. We
// approximate that by requiring the lead to be ≥3 words and not begin with an
// introductory word — short or transition-led leads are intro phrases, not
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss.
function commaSplices(text: string, out: ProseHint[]) {
let found = 0
for (const s of sentences(text)) {
if (found >= 2) break
SPLICE_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = SPLICE_RE.exec(s)) && found < 2) {
const commaIdx = s.indexOf(',', m.index)
const lead = s.slice(0, commaIdx).trim()
const words = lead.split(/\s+/).filter(Boolean)
const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? ''
if (words.length < 3 || INTRO_LEAD.has(firstWord)) continue
if (SPLICE_NONVERB.has(m[2].toLowerCase())) continue
found++
out.push({
id: `splice:${key(m[0])}`,
rule: 'splice',
zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
})
}
}
}
// Unclear antecedent — a sentence that opens with This/That/These/Those riding
// straight into a verb, with no noun naming what it points back to. Only flag
// when there's a prior sentence (so an antecedent is actually in question).
const ANTECEDENT_RE =
/^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/
function antecedents(text: string, out: ProseHint[]) {
const list = sentences(text)
let found = 0
for (let i = 1; i < list.length && found < 2; i++) {
const s = list[i]
const m = s.match(ANTECEDENT_RE)
if (m) {
found++
out.push({
id: `antecedent:${key(s)}`,
rule: 'antecedent',
zh: `${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”`,
en: `${m[1]}” here is a little vague — name what it refers to.`,
})
}
}
}
// Oxford comma — a 3+ item list ending “… X and Y” with no comma before the
// conjunction. Guarded by a clause-starter stoplist so we don't mistake a
// compound clause (“…, but she and I…”) for a list.
const OXFORD_RE = /(\w+),\s+([\w'-]+(?:\s+[\w'-]+){0,2})\s+(and|or)\s+[\w'-]+/g
const CLAUSE_STARTERS = new Set([
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if',
'although', 'though', 'since', 'as', 'and', 'or', 'nor', 'yet', 'then',
'however', 'whereas',
// Subject pronouns signal a clause, not a list item (“…, I went home and…”).
'i', 'you', 'he', 'she', 'it', 'we', 'they',
])
function oxford(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
let found = 0
OXFORD_RE.lastIndex = 0
while ((m = OXFORD_RE.exec(text)) && found < 1) {
const firstWord = m[2].split(/\s+/)[0]?.toLowerCase() ?? ''
if (CLAUSE_STARTERS.has(firstWord)) continue
found++
out.push({
id: `oxford:${key(m[0])}`,
rule: 'oxford',
zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
})
}
}
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
const A_BEFORE_VOWEL_RE = /\ba\s+([aeiou][a-z]+)\b/g
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
const AN_BEFORE_CONSONANT_RE = /\ban\s+([b-df-hj-np-tv-z][a-z]+)\b/g
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
function articles(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
let found = 0
A_BEFORE_VOWEL_RE.lastIndex = 0
while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) {
if (CONSONANT_SOUND_RE.test(m[1])) continue
found++
out.push({
id: `article-an:${key(m[1])}`,
rule: 'article',
zh: `元音开头的词前用 “an”“an ${m[1]}”。`,
en: `Before a vowel sound, use “an”: “an ${m[1]}”.`,
})
}
AN_BEFORE_CONSONANT_RE.lastIndex = 0
while ((m = AN_BEFORE_CONSONANT_RE.exec(text)) && found < 2) {
if (VOWEL_SOUND_RE.test(m[1])) continue
found++
out.push({
id: `article-a:${key(m[1])}`,
rule: 'article',
zh: `辅音开头的词前用 “a”“a ${m[1]}”。`,
en: `Before a consonant sound, use “a”: “a ${m[1]}”.`,
})
}
}
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
// be legitimate (“the fact that that happened”, “she had had enough”).
const DOUBLE_RE = /\b([A-Za-z]+)\s+\1\b/gi
const LEGIT_DOUBLES = new Set(['that', 'had'])
function doubles(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
let found = 0
DOUBLE_RE.lastIndex = 0
while ((m = DOUBLE_RE.exec(text)) && found < 1) {
const w = m[1].toLowerCase()
if (LEGIT_DOUBLES.has(w)) continue
found++
out.push({
id: `double:${key(m[0])}`,
rule: 'double',
zh: `${m[1]}” 好像写了两遍,检查一下哦。`,
en: `${m[1]} ${m[1]}” — looks like a word got doubled.`,
})
}
}
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
// edge, to avoid tangling with “i.e.” and the like.
const LOWER_I_RE = /(?:^|\s)i(?=\s|$)/m
function pronounI(text: string, out: ProseHint[]) {
if (LOWER_I_RE.test(text)) {
out.push({
id: 'cap-i',
rule: 'cap-i',
zh: '英文里的 “I”任何时候都要大写哦。',
en: 'In English, “I” is always written as a capital letter.',
})
}
}
// Sentence not starting with a capital. We look after a terminator, and ignore
// CJK sentences (she writes Mandarin too — those don't take Latin capitals).
const LOWER_START_RE = /[.!?]\s+([a-z])/
function sentenceCaps(text: string, out: ProseHint[]) {
if (LOWER_START_RE.test(text)) {
out.push({
id: 'cap-sentence',
rule: 'cap-sentence',
zh: '每个句子的开头,用大写字母开始吧。',
en: 'Start each new sentence with a capital letter.',
})
}
}
// A stray space *before* punctuation (a common habit carried from CJK spacing).
const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/
function spaceBeforePunct(text: string, out: ProseHint[]) {
if (SPACE_BEFORE_PUNCT_RE.test(text)) {
out.push({
id: 'space-punct',
rule: 'space-punct',
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
en: 'No space before punctuation — it tucks right against the word.',
})
}
}
// ── Chinese-L1 interference rules (Tier 1) ──────────────────────────────────
// Mandarin lacks plural inflection, articles, and verb agreement, so these
// patterns are the predictable places that grammar "leaks" into her English.
// All are closed-list or strong-signal to keep false positives near zero.
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
// essentially never valid with a trailing “s”, so the list is its own guard.
const UNCOUNTABLE_PLURAL_RE =
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/i
function uncountables(text: string, out: ProseHint[]) {
const m = UNCOUNTABLE_PLURAL_RE.exec(text)
UNCOUNTABLE_PLURAL_RE.lastIndex = 0
if (m) {
const singular = m[1].replace(/s$/i, '')
out.push({
id: `uncountable:${m[1].toLowerCase()}`,
rule: 'uncountable',
zh: `${m[1]}” 是不可数名词,不用加 s写 “${singular}” 就好。`,
en: `${m[1]}” is uncountable — drop the “s”: just “${singular}”.`,
})
}
}
// Languages, nationalities, days, and months are proper nouns in English but
// not in Mandarin, so they're routinely left lowercase. We list only the
// unambiguous ones — “may/march/august/china/turkey/polish” are skipped because
// they're also ordinary lowercase words and would misfire.
const PROPER_NOUNS =
'monday|tuesday|wednesday|thursday|friday|saturday|sunday|' +
'january|february|april|june|july|september|october|november|december|' +
'english|chinese|mandarin|cantonese|japanese|korean|french|spanish|german|' +
'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
'australian|mexican|brazilian|european'
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged.
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`)
function properCaps(text: string, out: ProseHint[]) {
const m = PROPER_NOUN_RE.exec(text)
if (m) {
const fixed = m[1][0].toUpperCase() + m[1].slice(1)
out.push({
id: `propercap:${m[1]}`,
rule: 'propercap',
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
})
}
}
// Third-person singular verb form, for the agreement rule's suggestion.
function third(verb: string): string {
const v = verb.toLowerCase()
const irregular: Record<string, string> = { have: 'has', do: 'does', go: 'goes' }
if (irregular[v]) return irregular[v]
if (/[^aeiou]y$/.test(v)) return v.slice(0, -1) + 'ies'
if (/(s|x|z|ch|sh)$/.test(v)) return v + 'es'
return v + 's'
}
// Subjectverb agreement after he/she/it: a bare (base-form) verb where the
// third-person “-s” form is required. We skip the causative/perception frames
// (“let it go”, “make it work”, “help it grow”) where the bare verb is correct.
const SVA_BASE =
'go|have|do|make|like|want|need|know|think|say|see|feel|get|take|come|give|' +
'run|play|work|live|look|seem|become|bring|buy|find|keep|leave|tell|try|call|' +
'move|turn|put|mean|show|use|love|hope|wish|believe|understand|remember|enjoy'
// The preceding word is optional so a sentence-initial subject (“She have…”)
// still matches; when present it lets us skip causative/perception frames.
const SVA_RE = new RegExp(`(?:(\\w+)\\s+)?\\b(he|she|it)\\s+(${SVA_BASE})\\b`, 'gi')
const CAUSATIVE = new Set([
'let', 'lets', 'make', 'makes', 'made', 'help', 'helps', 'helped', 'watch',
'watches', 'watched', 'see', 'sees', 'saw', 'hear', 'heard', 'have', 'has',
'had', 'lets', "let's",
])
function subjectVerbAgreement(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
let found = 0
SVA_RE.lastIndex = 0
while ((m = SVA_RE.exec(text)) && found < 2) {
if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
found++
const fixed = third(m[3])
out.push({
id: `sva:${m[2].toLowerCase()}:${m[3].toLowerCase()}`,
rule: 'sva',
zh: `主语是 he/she/it 时,动词要加 -s${m[2]} ${fixed}”。`,
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
})
}
}
// Singular noun after a cardinal number (“three apple”). Adjectives between the
// number and noun would misfire (“two small dogs”), so we only flag when the
// candidate is followed by a clause boundary — a verb, preposition, article, or
// punctuation — which means it's the noun itself, not a modifier.
const NUMBER_NOUN_RE =
/\b(two|three|four|five|six|seven|eight|nine|ten)\s+([a-z]{3,})\b(?=\s*(?:[.,!?;:]|$|\s(?:is|are|was|were|and|or|but|that|which|who|in|on|at|to|for|with|will|can|could|would|should|the|a|an)\b))/g
// Irregular plurals / mass nouns that are correct without an “s”.
const NON_S_PLURAL = new Set([
'people', 'children', 'men', 'women', 'fish', 'deer', 'sheep', 'feet', 'teeth',
'mice', 'geese', 'police', 'staff', 'series', 'species', 'aircraft', 'cattle',
'hundred', 'thousand', 'million', 'billion', 'dozen', 'percent',
])
function pluralAfterNumber(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
let found = 0
NUMBER_NOUN_RE.lastIndex = 0
while ((m = NUMBER_NOUN_RE.exec(text)) && found < 1) {
const noun = m[2].toLowerCase()
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
found++
out.push({
id: `plural:${key(m[0])}`,
rule: 'plural',
zh: `${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
})
}
}
// Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare
// possessive, so the article often gets stacked on top. One of the two must go.
const DOUBLE_DET_RE =
/\b(a|an|the)\s+(a|an|the|my|your|his|her|its|our|their)\b/gi
function doubleDeterminer(text: string, out: ProseHint[]) {
const m = DOUBLE_DET_RE.exec(text)
DOUBLE_DET_RE.lastIndex = 0
if (m) {
out.push({
id: `doubledet:${key(m[0])}`,
rule: 'doubledet',
zh: `${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
en: `${m[1]} ${m[2]}” stacks two determiners — keep just one.`,
})
}
}
// “there is” with a plural quantifier → should be “there are”. We trigger only
// on clearly-plural cues (skip “some/any”, which are fine with singular mass
// nouns: “there is some water”).
const THERE_IS_RE =
/\bthere(?:\s+is|'s|s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/i
function thereIsPlural(text: string, out: ProseHint[]) {
const m = THERE_IS_RE.exec(text)
THERE_IS_RE.lastIndex = 0
if (m) {
out.push({
id: `thereis:${m[1].toLowerCase()}`,
rule: 'thereis',
zh: `后面是复数时用 “there are”“there are ${m[1]}…”。`,
en: `With a plural, use “there are”: “there are ${m[1]}…”.`,
})
}
}
// ── confusables (Tier 2) ─────────────────────────────────────────────────────
// its / it's — only the two unambiguous directions: “it's own” (always its own)
// and “its a/an” (the possessive can't take an article → it's a/an).
const ITS_OWN_RE = /\bit's\s+own\b/i
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i
function itsConfusion(text: string, out: ProseHint[]) {
if (ITS_OWN_RE.test(text)) {
out.push({
id: 'its-own',
rule: 'its',
zh: '“its” = “it is”表示“它的”要用 “its”所以是 “its own”。',
en: '“its” means “it is” — the possessive is “its”: “its own”.',
})
return
}
const m = ITS_ARTICLE_RE.exec(text)
ITS_ARTICLE_RE.lastIndex = 0
if (m) {
out.push({
id: 'its-article',
rule: 'its',
zh: `这里应该是 “its ${m[1]}it is“its” 是“它的”。`,
en: `Here it should be “its ${m[1]}” (it is); “its” means belonging to it.`,
})
}
}
// Comparative + “then” where “than” is meant (“better then”, “more then”). We
// use an explicit comparative list rather than a generic “-er” pattern, which
// would snag “after then”, “however”, “remember”, etc.
const COMPARATIVES =
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer'
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})\\s+then\\b`, 'i')
function thanThen(text: string, out: ProseHint[]) {
const m = THAN_THEN_RE.exec(text)
if (m) {
out.push({
id: `than:${m[1].toLowerCase()}`,
rule: 'than',
zh: `比较的时候用 “than”不是 “then”${m[1]} than”。`,
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
})
}
}
// A transition word opening a sentence with no comma after it (“However we…”).
// Limited to conjunctive adverbs that strongly want the comma — sequence words
// like “Then/First/Finally” are left out (their comma is optional).
const INTRO_RE =
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
function introComma(text: string, out: ProseHint[]) {
let found = false
for (const s of sentences(text)) {
const m = s.match(INTRO_RE)
if (m) {
out.push({
id: `introcomma:${m[1].toLowerCase()}`,
rule: 'introcomma',
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
})
found = true
break
}
}
return found
}
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
// The comma case requires letters on both sides so numbers like 1,000 are safe;
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
const NO_SPACE_COMMA_RE = /[A-Za-z],[A-Za-z]/
const NO_SPACE_PERIOD_RE = /[a-z]{2,}\.[A-Z][a-z]/
function spaceAfterPunct(text: string, out: ProseHint[]) {
if (NO_SPACE_COMMA_RE.test(text) || NO_SPACE_PERIOD_RE.test(text)) {
out.push({
id: 'space-after',
rule: 'space-after',
zh: '逗号、句号后面要空一格,再接下一个词。',
en: 'Add a space after a comma or period before the next word.',
})
}
}
// ── orchestration ───────────────────────────────────────────────────────────
// Rules run in priority order — the ones the writer cares most about first, so
// that when several fire at once the companion leads with the weightiest note.
const RULES: Array<(text: string, out: ProseHint[]) => void> = [
runOns,
commaSplices,
antecedents,
oxford,
articles,
uncountables,
properCaps,
subjectVerbAgreement,
pluralAfterNumber,
doubleDeterminer,
thereIsPlural,
itsConfusion,
thanThen,
introComma,
doubles,
pronounI,
sentenceCaps,
spaceBeforePunct,
spaceAfterPunct,
]
// analyzeProse returns context-aware hints, highest-priority first. It bails on
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
export function analyzeProse(text: string): ProseHint[] {
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
if (englishWords < 8) return []
const out: ProseHint[] = []
for (const rule of RULES) rule(text, out)
return out
}

View File

@@ -17,7 +17,8 @@ export const ENCOURAGEMENTS: Line[] = [
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, thats much clearer.' }, { zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, thats much clearer.' },
] ]
// Gentle, periodic writing/ESL tips. Surfaced only when nothing else is showing. // Gentle, generic writing/ESL tips — the fallback when the rules-based prose
// checker (prose.ts) finds nothing concrete to point at in her current text.
export const TIPS: Line[] = [ export const TIPS: Line[] = [
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' }, { zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." }, { zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
@@ -47,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,
@@ -11,12 +12,14 @@ import {
pick, pick,
type Line, type Line,
} from './tips' } from './tips'
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
} }
@@ -24,10 +27,16 @@ 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
acceptTick: number acceptTick: number
// The document's plain text, used by the rules-based prose checker to offer
// context-aware writing notes instead of only generic tips.
text: string
} }
// Timing knobs (ms). Tuned to feel present but never naggy. // Timing knobs (ms). Tuned to feel present but never naggy.
@@ -35,14 +44,29 @@ const IDLE_MS = 75_000 // no edits → kitten naps
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
const BUBBLE_MS = 6_500 // how long a bubble lingers // How long a bubble lingers. These are *floors* — readBubbleMs extends them by
const CHEER_MS = 4_500 // shorter for quick cheers // how much there is to read, since she reads both the Mandarin and the English
// (and tips now quote a slice of her own sentence, so they run longer).
const BUBBLE_MS = 9_000 // baseline for a tip / break
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
const now = () => Date.now() const now = () => Date.now()
// Reading time for a bubble: a base floor by tone, stretched by the combined
// length of the Mandarin + English lines so denser advice stays up long enough
// to actually finish reading.
function readBubbleMs(b: Bubble): number {
const base = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
const chars = b.zh.length + b.en.length
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
}
// 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 }: 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)
@@ -54,12 +78,22 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
const nextMilestone = useRef(0) // index into MILESTONES const nextMilestone = useRef(0) // index into MILESTONES
const sleeping = useRef(false) const sleeping = useRef(false)
// Latest text for the prose checker, read lazily by the heartbeat (kept in a
// ref so per-keystroke changes don't re-arm the interval).
const textRef = useRef(text)
textRef.current = text
// Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged
// each cadence. A small ring — old ids age out and may resurface later.
const recentHints = useRef<string[]>([])
const lastRule = useRef<string>('')
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined) const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined) const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
// 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
@@ -67,15 +101,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
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)
clearTimeout(moodTimer.current) clearTimeout(moodTimer.current)
const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS const dur = readBubbleMs(b)
bubbleTimer.current = setTimeout(() => setBubble(null), dur) bubbleTimer.current = setTimeout(() => setBubble(null), dur)
moodTimer.current = setTimeout(() => setMood('idle'), dur) moodTimer.current = setTimeout(() => setMood('idle'), dur)
}, [bubble]) }, [bubble])
// Choose the next spontaneous tip. Prefer a real, context-aware note from the
// rules-based prose checker (it's actionable and clearly about *her* writing);
// fall back to a generic encouragement-style tip when the text reads clean or
// every finding was shown recently. Avoids repeating a finding or the same
// rule family back-to-back so the companion never feels like a broken record.
const nextTip = useCallback((): Bubble => {
const hints = analyzeProse(textRef.current)
const fresh = hints.find(
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
)
const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id))
if (hint) {
lastRule.current = hint.rule
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
return { zh: hint.zh, en: hint.en, tone: 'tip' }
}
return { ...pick(TIPS), tone: 'tip' }
}, [])
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
clearTimeout(bubbleTimer.current) clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current) clearTimeout(moodTimer.current)
@@ -83,6 +141,21 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
setMood('idle') setMood('idle')
}, []) }, [])
// Pause the auto-dismiss while she hovers a bubble — so a longer note never
// vanishes mid-read. Releasing leaves it up for a short, fixed grace so it
// doesn't linger forever once she looks away.
const holdBubble = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
}, [])
const releaseBubble = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS)
moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS)
}, [])
// Opening hello (once), after a short beat so it doesn't race the first paint. // Opening hello (once), after a short beat so it doesn't race the first paint.
useEffect(() => { useEffect(() => {
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200) const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
@@ -128,11 +201,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
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) {
@@ -161,14 +262,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
return return
} }
// Otherwise an occasional tip. // Otherwise an occasional tip — context-aware when her text gives us
// something concrete to gently point at, generic warmth otherwise.
if (t - lastTip.current > TIP_MIN_GAP) { if (t - lastTip.current > TIP_MIN_GAP) {
lastTip.current = t lastTip.current = t
say({ ...pick(TIPS), tone: 'tip' }, { proactive: true }) say(nextTip(), { proactive: true })
} }
}, 10_000) }, 10_000)
return () => clearInterval(id) return () => clearInterval(id)
}, [bubble, say]) }, [bubble, say, nextTip])
useEffect( useEffect(
() => () => { () => () => {
@@ -178,5 +280,5 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
[], [],
) )
return { mood, bubble, dismiss } return { mood, bubble, dismiss, holdBubble, releaseBubble }
} }

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" />