Compare commits
10 Commits
phase-10-o
...
feat/write
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db737fa612 | ||
|
|
6e6e4edce7 | ||
|
|
58c326d0cf | ||
|
|
045ec19b92 | ||
|
|
f6fa73b17b | ||
|
|
8cf78130bf | ||
|
|
2487e73551 | ||
|
|
0a1ea225dd | ||
|
|
69ecb79da1 | ||
|
|
f3c4fe2c22 |
@@ -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
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
|
- Tests: `tags_test.go` (full lifecycle — create/idempotent/color-coerce/rename-recolor/assign/unassign/doc-list inclusion/roster counts/delete-cascade/404s), `search_test.go` (EN FTS, CJK FTS, 2-char CJK LIKE fallback, title-only, case-insensitive, empty/no-match, **edit re-indexes via the update trigger**). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs the binary on a throwaway DB (port 8061, LLM pointed at a dead host): search EN/CJK/2-char all highlighted, tag create+assign+roster-counts+doc-list-tags+delete-cascade, check→502 (warm path), new CSS classes (`petal-tag-chip`/`petal-scrim`/`petal-drawer-open`/`pointer:coarse`) and the 小助手在休息 string present in the served bundle. FTS backfill of pre-existing docs verified separately.
|
||||||
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
|
- **Known limitations**: trigram FTS snippets/ranking treat the query as a contiguous phrase (multi-term relevance is substring, not BM25-per-term) — fine for a personal corpus. Search is title+body only (not tag names). Hover gloss and right-click word lookup remain pointer-oriented (long-press contextmenu on touch is browser-dependent); the spelling/suggestion cards and rewrite bubble are fully touch-reachable.
|
||||||
|
|
||||||
|
### Phase 11 — Writer power-ups ✅
|
||||||
|
- [x] **In-document Find & Replace (Ctrl/Cmd+F)** — `SearchHighlight` ProseMirror extension (decorations, not marks — same anchoring discipline as the suggestion/spell layers; matches recomputed per-textblock on every edit, never stranded). `FindReplace` bar (bilingual zh-first): live match highlighting, ↑/↓ step-through with no-selection DOM scroll-into-view (so it never pops the rewrite bubble), match-case toggle, replace / replace-all (replace-all applies back-to-front so earlier edits don't shift later positions). Honey wash on all matches, rose ring on the active one.
|
||||||
|
- [x] **Read-aloud / TTS** (`web/src/audio/speech.ts`) — Web Speech API, offline, feature-detected. 🔊 in the `WordCard` (pronounce the word) and the selection bubble (read the selection). Pairs with the phonetic line.
|
||||||
|
- [x] **Keyboard + touch access to the ESL helpers** — refactored the right-click word-lookup into a position-based `openWordLookup(pos)`; now also driven by **Ctrl/Cmd+D** (look up the word at the caret) and a **touch long-press** (~500ms, the touch equivalent of right-click). **Ctrl/Cmd+J** rewrites the selection more naturally. (Closes the "pointer-only" limitation noted in Phase 10.)
|
||||||
|
- [x] **Whole-corpus backup** — `GET /api/docs/export-all?format=md|docx|…` zips every doc (reuses the per-doc renderers; de-duplicates same-titled filenames; dated `petal-backup-YYYY-MM-DD.zip`). Static route takes priority over `/{id}` in chi — covered by `TestExportAll`. Sidebar footer "备份 · Back up all: Word / Markdown" download links.
|
||||||
|
- [x] **Smart typography** (`Typography.ts`) — dependency-free input rules: curly quotes, em-dash (`--`), ellipsis (`...`). ASCII-only triggers so CJK fullwidth punctuation is untouched; every rule is plain-Undo-able.
|
||||||
|
- [x] **Org niceties** — duplicate-a-document (App `handleDuplicate` → "… (副本)", copies body/tone, not tags), sidebar **sort** (Recent / Title / Longest), and a **document outline** popover in the toolbar (headings → click to scroll, indented by level).
|
||||||
|
- [x] **English phonetic (pivot from pinyin)** — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently). `scripts/build_phonetic.py` extracts ECDICT's `phonetic` column (same source/freq-gate as the gloss); `phonetic.json.gz` embedded + lazily loaded; `Result.Phonetic` resolved via the same de-inflecting candidate walk; shown as `/ˈrɪvər/` in the WordCard. **Full dataset built from ECDICT: 46,579 words (361KB gz)**, in line with the other lexicon assets. The script also has a `--seed` mode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist.
|
||||||
|
- Verified: go build/vet/test (incl. new `TestExportAll`), tsc, vite build all clean; live smoke vs throwaway binaries — `/word/river|running|serendipity|rivers` all return phonetic (`rivers`→`river` de-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with `/{id}`.
|
||||||
|
|
||||||
### Deferred (post-v1-local)
|
### Deferred (post-v1-local)
|
||||||
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
||||||
- [ ] Copyleaks Tier-2 + webhook HMAC
|
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||||
@@ -105,6 +115,7 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
||||||
|
|
||||||
## Session log
|
## Session log
|
||||||
|
- 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace** — `SearchHighlight` decoration extension + `FindReplace` bar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) **read-aloud** Web Speech util + 🔊 in WordCard & selection bubble; (3) **keyboard/touch access** — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactored `handleContextMenu` → shared `openWordLookup(pos)`); (4) **export-all** backup zip (`GET /api/docs/export-all`, `TestExportAll`, sidebar download links); (5) **smart typography** input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) **duplicate doc + sidebar sort + outline popover**; (7) **English phonetic** (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) via `scripts/build_phonetic.py` + embedded `phonetic.json.gz` + `Result.Phonetic` + WordCard `/ˈrɪvər/` line — **full 46,579-word dataset built from ECDICT** (the csv re-download worked; `--seed` mode kept as a csv-free fallback). Also folded in this session: the **selection-bubble vs copy/paste fix** (bubble deferred to pointer-up + container `pointer-events:none` so it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user.
|
||||||
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
||||||
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
|
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
|
||||||
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
|
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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("", 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>"
|
||||||
|
|||||||
@@ -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)", "", "| 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
132
internal/images/handler.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Package images implements a tiny content-addressed image store: writers upload
|
||||||
|
// images from the editor, they're saved to disk under the configured directory,
|
||||||
|
// and served back by hashed filename. Content addressing means the same image
|
||||||
|
// pasted twice is stored once, and URLs are stable and cacheable forever.
|
||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxUploadBytes caps a single image at 10 MiB — generous for a writing tool,
|
||||||
|
// small enough to keep a careless paste from filling the disk.
|
||||||
|
const maxUploadBytes = 10 << 20
|
||||||
|
|
||||||
|
// extByContentType maps the image types we accept to a canonical extension. The
|
||||||
|
// allowlist doubles as validation: anything not here is rejected.
|
||||||
|
var extByContentType = map[string]string{
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/svg+xml": ".svg",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves the upload + fetch endpoints, backed by a directory on disk.
|
||||||
|
type Handler struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Handler, ensuring the storage directory exists.
|
||||||
|
func New(dir string) (*Handler, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Handler{dir: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes mounts the image endpoints. Mount under "/images" so the full paths are
|
||||||
|
// POST /api/images (upload) and GET /api/images/{name} (fetch).
|
||||||
|
func (h *Handler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/", h.upload)
|
||||||
|
r.Get("/{name}", h.serve)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload accepts a single multipart "image" field, sniffs and validates its
|
||||||
|
// type, and writes it under a content hash so identical images dedupe. Responds
|
||||||
|
// with the served URL the editor inserts.
|
||||||
|
func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||||
|
file, _, err := r.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "expected an 'image' file field", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not read upload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust a sniff over the client-declared type. SVG isn't reliably sniffable
|
||||||
|
// (DetectContentType returns text/plain or text/xml), so fall back to a
|
||||||
|
// lightweight tag check for it.
|
||||||
|
ct := http.DetectContentType(data)
|
||||||
|
ext, ok := extByContentType[ct]
|
||||||
|
if !ok {
|
||||||
|
if looksLikeSVG(data) {
|
||||||
|
ext, ok = ".svg", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unsupported image type", http.StatusUnsupportedMediaType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
name := hex.EncodeToString(sum[:])[:32] + ext
|
||||||
|
path := filepath.Join(h.dir, name)
|
||||||
|
|
||||||
|
// Skip the write if this exact content is already stored.
|
||||||
|
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
http.Error(w, "could not store image", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/api/images/" + name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve returns a stored image by its hashed filename. The filename is validated
|
||||||
|
// to be a bare name (no path separators) so it can't escape the storage dir, and
|
||||||
|
// served with a long-lived cache header since content-addressed URLs never change.
|
||||||
|
func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := chi.URLParam(r, "name")
|
||||||
|
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(h.dir, filepath.Base(name))
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeSVG does a cheap check for an <svg root tag near the start of the
|
||||||
|
// file, since DetectContentType doesn't recognize SVG.
|
||||||
|
func looksLikeSVG(data []byte) bool {
|
||||||
|
head := data
|
||||||
|
if len(head) > 512 {
|
||||||
|
head = head[:512]
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(string(head)), "<svg")
|
||||||
|
}
|
||||||
97
internal/images/handler_test.go
Normal file
97
internal/images/handler_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// a 1x1 transparent PNG.
|
||||||
|
var pngBytes = []byte{
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||||
|
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||||
|
0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadReq(t *testing.T, field string, data []byte) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
fw, err := mw.CreateFormFile(field, "x.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fw.Write(data)
|
||||||
|
mw.Close()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadAndServe(t *testing.T) {
|
||||||
|
h, err := New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := h.Routes()
|
||||||
|
|
||||||
|
// Upload a PNG → expect a JSON url under /api/images/.
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, uploadReq(t, "image", pngBytes))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var resp struct{ URL string }
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") {
|
||||||
|
t.Fatalf("unexpected url %q", resp.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same content uploaded again dedupes to the same URL.
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec2, uploadReq(t, "image", pngBytes))
|
||||||
|
var resp2 struct{ URL string }
|
||||||
|
json.Unmarshal(rec2.Body.Bytes(), &resp2)
|
||||||
|
if resp2.URL != resp.URL {
|
||||||
|
t.Fatalf("expected dedup to same url, got %q vs %q", resp2.URL, resp.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch it back.
|
||||||
|
name := strings.TrimPrefix(resp.URL, "/api/images/")
|
||||||
|
rec3 := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/"+name, nil))
|
||||||
|
if rec3.Code != http.StatusOK {
|
||||||
|
t.Fatalf("serve code=%d", rec3.Code)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rec3.Body.Bytes(), pngBytes) {
|
||||||
|
t.Fatal("served bytes differ from uploaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadRejectsNonImage(t *testing.T) {
|
||||||
|
h, _ := New(t.TempDir())
|
||||||
|
r := h.Routes()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
|
||||||
|
if rec.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Fatalf("expected 415, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeMissing(t *testing.T) {
|
||||||
|
h, _ := New(t.TempDir())
|
||||||
|
r := h.Routes()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/deadbeef.png", nil))
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,3 +27,12 @@ var synonymsGz []byte
|
|||||||
//
|
//
|
||||||
//go:embed data/gloss.json.gz
|
//go:embed data/gloss.json.gz
|
||||||
var glossGz []byte
|
var glossGz []byte
|
||||||
|
|
||||||
|
// phoneticGz is the gzipped English-phonetic map: word → IPA, lowercase keys.
|
||||||
|
// Built from ECDICT's phonetic column (scripts/build_phonetic.py). Shown beside
|
||||||
|
// the read-aloud button so the writer — a Mandarin speaker learning English —
|
||||||
|
// can see how to say the word. Ships with a small common-word seed; a full build
|
||||||
|
// broadens coverage.
|
||||||
|
//
|
||||||
|
//go:embed data/phonetic.json.gz
|
||||||
|
var phoneticGz []byte
|
||||||
|
|||||||
BIN
internal/lexicon/data/phonetic.json.gz
Normal file
BIN
internal/lexicon/data/phonetic.json.gz
Normal file
Binary file not shown.
@@ -26,6 +26,7 @@ type Meaning struct {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Word string `json:"word"`
|
Word string `json:"word"`
|
||||||
Gloss string `json:"gloss"`
|
Gloss string `json:"gloss"`
|
||||||
|
Phonetic string `json:"phonetic"` // IPA for the English word; "" when absent
|
||||||
Definitions []Meaning `json:"definitions"`
|
Definitions []Meaning `json:"definitions"`
|
||||||
Synonyms []string `json:"synonyms"`
|
Synonyms []string `json:"synonyms"`
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,7 @@ type Lexicon struct {
|
|||||||
defs map[string][][]string // word → [[pos, def, example], …]
|
defs map[string][][]string // word → [[pos, def, example], …]
|
||||||
synonyms map[string][]string // word → [synonym, …]
|
synonyms map[string][]string // word → [synonym, …]
|
||||||
gloss map[string]string // word → Chinese gloss
|
gloss map[string]string // word → Chinese gloss
|
||||||
|
phonetic map[string]string // word → IPA
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
||||||
@@ -75,6 +77,10 @@ func (l *Lexicon) load() {
|
|||||||
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := gunzipJSON(phoneticGz, &l.phonetic); err != nil {
|
||||||
|
l.loadErr = fmt.Errorf("load phonetic: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +101,9 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.Gloss = lookupGloss(l.gloss, norm)
|
res.Gloss = lookupGloss(l.gloss, norm)
|
||||||
|
// Phonetic is a word→string map like the gloss, so the same de-inflecting
|
||||||
|
// candidate walk resolves "running" → "run", etc.
|
||||||
|
res.Phonetic = lookupGloss(l.phonetic, norm)
|
||||||
|
|
||||||
if raw := lookupDefs(l.defs, norm); raw != nil {
|
if raw := lookupDefs(l.defs, norm); raw != nil {
|
||||||
for _, m := range raw {
|
for _, m := range raw {
|
||||||
|
|||||||
101
scripts/build_phonetic.py
Normal file
101
scripts/build_phonetic.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the embedded English-phonetic dataset.
|
||||||
|
|
||||||
|
Petal's writer is a native Mandarin speaker learning English, so the useful
|
||||||
|
pronunciation aid is *how to say the English word* — shown in the word popover
|
||||||
|
beside the 🔊 read-aloud button. (Pinyin would annotate Chinese she already
|
||||||
|
reads fluently, so it isn't built.) The phonetic spelling comes from the same
|
||||||
|
ECDICT source as the Chinese gloss, which already carries a `phonetic` column.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
|
||||||
|
# Full build from ECDICT (same CSV used by build_gloss.py):
|
||||||
|
curl -sL https://raw.githubusercontent.com/skywind3000/ECDICT/master/ecdict.csv -o ecdict.csv
|
||||||
|
python3 scripts/build_phonetic.py ecdict.csv internal/lexicon/data/phonetic.json.gz
|
||||||
|
|
||||||
|
# Write just the built-in common-word seed (no CSV needed — what ships in-repo
|
||||||
|
# so the feature works out of the box until a full build is run):
|
||||||
|
python3 scripts/build_phonetic.py --seed internal/lexicon/data/phonetic.json.gz
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Same frequency gate as the gloss build, so coverage matches the words an ESL
|
||||||
|
# writer actually meets and the asset stays small.
|
||||||
|
MAX_RANK = 50000
|
||||||
|
|
||||||
|
# Single English word (letters with internal apostrophe/hyphen).
|
||||||
|
WORD_RE = re.compile(r"^[a-z][a-z'\-]*[a-z]$|^[a-z]$")
|
||||||
|
|
||||||
|
# A small, hand-checked seed of common words → IPA. This ships in the repo so the
|
||||||
|
# phonetic line is live immediately; a full ECDICT build overwrites it with broad
|
||||||
|
# coverage. Kept deliberately short and correct rather than large and shaky.
|
||||||
|
SEED = {
|
||||||
|
"hello": "həˈloʊ", "world": "wɜːrld", "people": "ˈpiːpl", "water": "ˈwɔːtər",
|
||||||
|
"river": "ˈrɪvər", "house": "haʊs", "school": "skuːl", "friend": "frɛnd",
|
||||||
|
"family": "ˈfæməli", "mother": "ˈmʌðər", "father": "ˈfɑːðər", "woman": "ˈwʊmən",
|
||||||
|
"language": "ˈlæŋɡwɪdʒ", "english": "ˈɪŋɡlɪʃ", "write": "raɪt", "writing": "ˈraɪtɪŋ",
|
||||||
|
"read": "riːd", "word": "wɜːrd", "sentence": "ˈsɛntəns", "beautiful": "ˈbjuːtɪfl",
|
||||||
|
"happy": "ˈhæpi", "love": "lʌv", "thought": "θɔːt", "through": "θruː",
|
||||||
|
"though": "ðoʊ", "enough": "ɪˈnʌf", "question": "ˈkwɛstʃən", "answer": "ˈænsər",
|
||||||
|
"because": "bɪˈkɔːz", "people's": "ˈpiːplz", "important": "ɪmˈpɔːrtnt",
|
||||||
|
"different": "ˈdɪfərənt", "remember": "rɪˈmɛmbər", "together": "təˈɡɛðər",
|
||||||
|
"morning": "ˈmɔːrnɪŋ", "tonight": "təˈnaɪt", "yesterday": "ˈjɛstərdeɪ",
|
||||||
|
"tomorrow": "təˈmɒroʊ", "weather": "ˈwɛðər", "color": "ˈkʌlər", "colour": "ˈkʌlər",
|
||||||
|
"favourite": "ˈfeɪvərɪt", "favorite": "ˈfeɪvərɪt", "comfortable": "ˈkʌmftəbl",
|
||||||
|
"vegetable": "ˈvɛdʒtəbl", "interesting": "ˈɪntrəstɪŋ", "necessary": "ˈnɛsəsɛri",
|
||||||
|
"february": "ˈfɛbruɛri", "wednesday": "ˈwɛnzdeɪ",
|
||||||
|
"island": "ˈaɪlənd", "knife": "naɪf", "know": "noʊ", "knee": "niː",
|
||||||
|
"hour": "ˈaʊər", "honest": "ˈɒnɪst", "listen": "ˈlɪsn", "castle": "ˈkæsl",
|
||||||
|
"thank": "θæŋk", "think": "θɪŋk", "thing": "θɪŋ", "three": "θriː",
|
||||||
|
"voice": "vɔɪs", "choice": "tʃɔɪs", "nature": "ˈneɪtʃər", "picture": "ˈpɪktʃər",
|
||||||
|
"future": "ˈfjuːtʃər", "culture": "ˈkʌltʃər", "measure": "ˈmɛʒər",
|
||||||
|
"usually": "ˈjuːʒuəli", "decision": "dɪˈsɪʒn", "delicious": "dɪˈlɪʃəs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_phonetic(p: str) -> str:
|
||||||
|
"""Normalize an ECDICT phonetic field: trim, strip wrapping slashes/brackets."""
|
||||||
|
p = p.strip().strip("/[]").strip()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def build_from_csv(src: str) -> dict:
|
||||||
|
out = {}
|
||||||
|
with open(src, newline="", encoding="utf-8") as fh:
|
||||||
|
for row in csv.DictReader(fh):
|
||||||
|
word = (row.get("word") or "").strip().lower()
|
||||||
|
phon = clean_phonetic(row.get("phonetic") or "")
|
||||||
|
if not word or not phon or not WORD_RE.match(word):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rank = int(row.get("frq") or 0) or int(row.get("bnc") or 0)
|
||||||
|
except ValueError:
|
||||||
|
rank = 0
|
||||||
|
if rank <= 0 or rank > MAX_RANK:
|
||||||
|
continue
|
||||||
|
out[word] = phon
|
||||||
|
# Make sure the curated seed is always present (and authoritative).
|
||||||
|
out.update(SEED)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
if args[0] == "--seed":
|
||||||
|
data, dest = dict(SEED), args[1]
|
||||||
|
else:
|
||||||
|
src, dest = args[0], args[1]
|
||||||
|
data = build_from_csv(src)
|
||||||
|
with gzip.open(dest, "wt", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
print(f"wrote {len(data)} phonetic entries to {dest}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
165
scripts/gen_sounds.py
Normal file
165
scripts/gen_sounds.py
Normal 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
488
web/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
BIN
web/src/assets/sounds/baoding.mp3
Normal file
BIN
web/src/assets/sounds/baoding.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/block.mp3
Normal file
BIN
web/src/assets/sounds/block.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/error.mp3
Normal file
BIN
web/src/assets/sounds/error.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/milestone.mp3
Normal file
BIN
web/src/assets/sounds/milestone.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop1.mp3
Normal file
BIN
web/src/assets/sounds/pop1.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop2.mp3
Normal file
BIN
web/src/assets/sounds/pop2.mp3
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/tok.mp3
Normal file
BIN
web/src/assets/sounds/tok.mp3
Normal file
Binary file not shown.
194
web/src/audio/sounds.ts
Normal file
194
web/src/audio/sounds.ts
Normal 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
32
web/src/audio/speech.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Read-aloud via the browser's Web Speech API — an offline pronunciation aid for
|
||||||
|
// an ESL writer: hear how an English word or passage sounds. No model, no
|
||||||
|
// network, no extra weight in the binary. Feature-detected so the buttons that
|
||||||
|
// use it can hide where speech isn't available.
|
||||||
|
|
||||||
|
export function speechSupported(): boolean {
|
||||||
|
return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickVoice prefers an exact locale match (e.g. en-US), then any voice in the
|
||||||
|
// same language family (en-*). Returns undefined to let the engine default.
|
||||||
|
function pickVoice(lang: string): SpeechSynthesisVoice | undefined {
|
||||||
|
const voices = window.speechSynthesis.getVoices()
|
||||||
|
const base = lang.split('-')[0]
|
||||||
|
return voices.find((v) => v.lang === lang) || voices.find((v) => v.lang?.startsWith(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
|
||||||
|
// don't queue up. `lang` defaults to US English (the language she's learning);
|
||||||
|
// pass a zh locale to hear a Chinese passage. A touch slower than default so
|
||||||
|
// learners can follow along.
|
||||||
|
export function speak(text: string, lang = 'en-US'): void {
|
||||||
|
if (!speechSupported() || !text.trim()) return
|
||||||
|
const synth = window.speechSynthesis
|
||||||
|
synth.cancel()
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
|
utterance.lang = lang
|
||||||
|
const voice = pickVoice(lang)
|
||||||
|
if (voice) utterance.voice = voice
|
||||||
|
utterance.rate = 0.95
|
||||||
|
synth.speak(utterance)
|
||||||
|
}
|
||||||
@@ -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)',
|
||||||
|
|||||||
250
web/src/components/Companion/prose.test.ts
Normal file
250
web/src/components/Companion/prose.test.ts
Normal 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('subject–verb 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 / it’s', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
577
web/src/components/Companion/prose.ts
Normal file
577
web/src/components/Companion/prose.ts
Normal 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject–verb 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', 'let’s', "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: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||||
|
en: '“it’s” 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: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
||||||
|
en: `Here it should be “it’s ${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
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ export const ENCOURAGEMENTS: Line[] = [
|
|||||||
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s 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 ✨ let’s keep going!',
|
en: 'Welcome back ✨ let’s 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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
import { api, type DocSummary, type Tag, type TagColor } from '../../api/client'
|
||||||
import { DocListItem } from './DocListItem'
|
import { DocListItem } from './DocListItem'
|
||||||
import { SearchBox } from './SearchBox'
|
import { SearchBox } from './SearchBox'
|
||||||
import { TagChip } from './TagChip'
|
import { TagChip } from './TagChip'
|
||||||
@@ -11,10 +11,19 @@ interface Props {
|
|||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
|
onDuplicate: (id: string) => void
|
||||||
onToggleTag: (docId: string, tag: Tag) => void
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
|
||||||
|
type SortMode = 'recent' | 'title' | 'longest'
|
||||||
|
const SORTS: { value: SortMode; label: string }[] = [
|
||||||
|
{ value: 'recent', label: '最近 · Recent' },
|
||||||
|
{ value: 'title', label: '标题 · Title' },
|
||||||
|
{ value: 'longest', label: '字数 · Longest' },
|
||||||
|
]
|
||||||
|
|
||||||
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
||||||
// button, and the document browser (each row showing its tag chips).
|
// button, and the document browser (each row showing its tag chips).
|
||||||
export function DocList({
|
export function DocList({
|
||||||
@@ -24,6 +33,7 @@ export function DocList({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onCreate,
|
onCreate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
onToggleTag,
|
onToggleTag,
|
||||||
onCreateTag,
|
onCreateTag,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -31,11 +41,21 @@ export function DocList({
|
|||||||
// disappears from the roster.
|
// disappears from the roster.
|
||||||
const [filterId, setFilterId] = useState<string | null>(null)
|
const [filterId, setFilterId] = useState<string | null>(null)
|
||||||
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
||||||
|
const [sort, setSort] = useState<SortMode>('recent')
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(() => {
|
||||||
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
|
const base = activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs
|
||||||
[docs, activeFilter],
|
if (sort === 'recent') return base // already updated_at-desc from the server
|
||||||
)
|
const arr = [...base]
|
||||||
|
if (sort === 'title') {
|
||||||
|
arr.sort((a, b) =>
|
||||||
|
(a.title || 'Untitled').localeCompare(b.title || 'Untitled', undefined, { sensitivity: 'base' }),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
arr.sort((a, b) => b.word_count - a.word_count)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}, [docs, activeFilter, sort])
|
||||||
|
|
||||||
// Only surface tags that are actually in use, so the filter bar stays tidy.
|
// Only surface tags that are actually in use, so the filter bar stays tidy.
|
||||||
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
|
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
|
||||||
@@ -72,6 +92,25 @@ export function DocList({
|
|||||||
<span className="text-base leading-none">+</span> New Doc
|
<span className="text-base leading-none">+</span> New Doc
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{docs.length > 1 && (
|
||||||
|
<div className="flex items-center justify-end gap-1.5 px-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
<span aria-hidden>↕</span>
|
||||||
|
<select
|
||||||
|
aria-label="Sort documents"
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value as SortMode)}
|
||||||
|
className="bg-transparent text-xs font-semibold focus:outline-none"
|
||||||
|
style={{ color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||||
@@ -86,12 +125,30 @@ export function DocList({
|
|||||||
roster={roster}
|
roster={roster}
|
||||||
onSelect={() => onSelect(doc.id)}
|
onSelect={() => onSelect(doc.id)}
|
||||||
onDelete={() => onDelete(doc.id)}
|
onDelete={() => onDelete(doc.id)}
|
||||||
|
onDuplicate={() => onDuplicate(doc.id)}
|
||||||
onToggleTag={onToggleTag}
|
onToggleTag={onToggleTag}
|
||||||
onCreateTag={onCreateTag}
|
onCreateTag={onCreateTag}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Whole-corpus backup — "download all my writing". Plain download links so
|
||||||
|
the browser saves the zip; no extra app state needed. */}
|
||||||
|
{docs.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-1 pt-1 text-xs"
|
||||||
|
style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<span className="font-semibold">备份 · Back up all:</span>
|
||||||
|
<a href={api.exportAllUrl('docx')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
||||||
|
Word
|
||||||
|
</a>
|
||||||
|
<a href={api.exportAllUrl('md')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
||||||
|
Markdown
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
roster: Tag[]
|
roster: Tag[]
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
onToggleTag: (docId: string, tag: Tag) => void
|
onToggleTag: (docId: string, tag: Tag) => void
|
||||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export function DocListItem({
|
|||||||
roster,
|
roster,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
onToggleTag,
|
onToggleTag,
|
||||||
onCreateTag,
|
onCreateTag,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -65,6 +67,19 @@ export function DocListItem({
|
|||||||
>
|
>
|
||||||
🏷️
|
🏷️
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Duplicate document"
|
||||||
|
title="副本 · Duplicate"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDuplicate()
|
||||||
|
}}
|
||||||
|
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
⧉
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Delete document"
|
aria-label="Delete document"
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
238
web/src/components/Editor/FindReplace.tsx
Normal file
238
web/src/components/Editor/FindReplace.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { Editor } from '@tiptap/react'
|
||||||
|
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
|
||||||
|
|
||||||
|
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
|
||||||
|
// SearchHighlight decoration layer: typing updates the highlighted matches, the
|
||||||
|
// arrows step through them (scrolling each into view), and replace / replace-all
|
||||||
|
// edit the document in place. Bilingual, zh-first, matching the editor chrome.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
|
||||||
|
// Scroll the active match into the middle of the viewport without moving the
|
||||||
|
// selection (which would otherwise pop the rewrite bubble).
|
||||||
|
function scrollToActive(editor: Editor) {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
if (st.active < 0) return
|
||||||
|
const m = st.matches[st.active]
|
||||||
|
const dom = editor.view.domAtPos(m.from)
|
||||||
|
const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : (dom.node as HTMLElement)
|
||||||
|
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindReplace({ editor, onClose }: Props) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [replacement, setReplacement] = useState('')
|
||||||
|
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||||
|
const [showReplace, setShowReplace] = useState(false)
|
||||||
|
// Mirror of the plugin's match count + active index for the "n / m" readout.
|
||||||
|
const [{ count, active }, setCounts] = useState({ count: 0, active: -1 })
|
||||||
|
const findRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const sync = useCallback(() => {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
setCounts({ count: st.matches.length, active: st.active })
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
// Push the query into the decoration layer whenever it (or case-sensitivity)
|
||||||
|
// changes, then jump to the first match.
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch(editor.state, editor.view.dispatch, query, caseSensitive)
|
||||||
|
sync()
|
||||||
|
scrollToActive(editor)
|
||||||
|
}, [editor, query, caseSensitive, sync])
|
||||||
|
|
||||||
|
// Tear the highlight layer down when the bar closes.
|
||||||
|
useEffect(() => () => clearSearch(editor.state, editor.view.dispatch), [editor])
|
||||||
|
|
||||||
|
// Focus the find field on open.
|
||||||
|
useEffect(() => {
|
||||||
|
findRef.current?.focus()
|
||||||
|
findRef.current?.select()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
if (!st.matches.length) return
|
||||||
|
setActive(editor.state, editor.view.dispatch, st.active + delta)
|
||||||
|
sync()
|
||||||
|
scrollToActive(editor)
|
||||||
|
},
|
||||||
|
[editor, sync],
|
||||||
|
)
|
||||||
|
|
||||||
|
const replaceActive = useCallback(() => {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
if (st.active < 0) return
|
||||||
|
const m = st.matches[st.active]
|
||||||
|
const tr = editor.state.tr
|
||||||
|
if (replacement) tr.insertText(replacement, m.from, m.to)
|
||||||
|
else tr.delete(m.from, m.to)
|
||||||
|
editor.view.dispatch(tr)
|
||||||
|
sync()
|
||||||
|
scrollToActive(editor)
|
||||||
|
}, [editor, replacement, sync])
|
||||||
|
|
||||||
|
const replaceAll = useCallback(() => {
|
||||||
|
const st = getSearchState(editor.state)
|
||||||
|
if (!st.matches.length) return
|
||||||
|
const tr = editor.state.tr
|
||||||
|
// Apply back-to-front so earlier edits don't shift later match positions.
|
||||||
|
for (let i = st.matches.length - 1; i >= 0; i--) {
|
||||||
|
const m = st.matches[i]
|
||||||
|
if (replacement) tr.insertText(replacement, m.from, m.to)
|
||||||
|
else tr.delete(m.from, m.to)
|
||||||
|
}
|
||||||
|
editor.view.dispatch(tr)
|
||||||
|
sync()
|
||||||
|
}, [editor, replacement, sync])
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
borderRadius: 'var(--radius-input)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
fontFamily: CJK,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="petal-no-print absolute right-2 top-2 z-40 flex flex-col gap-1.5 p-2"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Find and replace"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 320,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReplace((v) => !v)}
|
||||||
|
aria-label={showReplace ? 'Hide replace' : 'Show replace'}
|
||||||
|
title={showReplace ? 'Hide replace' : 'Show replace'}
|
||||||
|
className="flex h-8 w-6 items-center justify-center text-xs"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
{showReplace ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={findRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
go(e.shiftKey ? -1 : 1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="查找 · Find"
|
||||||
|
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-14 shrink-0 text-center text-xs tabular-nums"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
{count ? `${active + 1} / ${count}` : query ? '无 · 0' : ''}
|
||||||
|
</span>
|
||||||
|
<FindBtn label="Previous match" disabled={!count} onClick={() => go(-1)}>↑</FindBtn>
|
||||||
|
<FindBtn label="Next match" disabled={!count} onClick={() => go(1)}>↓</FindBtn>
|
||||||
|
<FindBtn
|
||||||
|
label="Match case"
|
||||||
|
active={caseSensitive}
|
||||||
|
onClick={() => setCaseSensitive((v) => !v)}
|
||||||
|
title="Match case · 区分大小写"
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</FindBtn>
|
||||||
|
<FindBtn label="Close" onClick={onClose} title="Close · 关闭">✕</FindBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showReplace && (
|
||||||
|
<div className="flex items-center gap-1.5 pl-7">
|
||||||
|
<input
|
||||||
|
value={replacement}
|
||||||
|
onChange={(e) => setReplacement(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
replaceActive()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="替换为 · Replace"
|
||||||
|
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={replaceActive}
|
||||||
|
disabled={!count}
|
||||||
|
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-semibold disabled:opacity-40"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
替换
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={replaceAll}
|
||||||
|
disabled={!count}
|
||||||
|
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-bold disabled:opacity-40"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FindBtn({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
title?: string
|
||||||
|
active?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={title ?? label}
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex h-8 min-w-8 items-center justify-center px-1.5 text-sm font-semibold disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-input)',
|
||||||
|
color: active ? 'var(--color-accent-hover)' : 'var(--color-muted)',
|
||||||
|
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
web/src/components/Editor/FontSize.ts
Normal file
51
web/src/components/Editor/FontSize.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
|
||||||
|
// FontSize adds a `fontSize` attribute to the textStyle mark so a writer can
|
||||||
|
// pick a size preset (Small / Normal / Large / Title) from the toolbar. It rides
|
||||||
|
// on TextStyle (already loaded) rather than introducing a new mark, so it stacks
|
||||||
|
// cleanly with color and other inline styling.
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
fontSize: {
|
||||||
|
setFontSize: (size: string) => ReturnType
|
||||||
|
unsetFontSize: () => ReturnType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontSize = Extension.create({
|
||||||
|
name: 'fontSize',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return { types: ['textStyle'] }
|
||||||
|
},
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: this.options.types,
|
||||||
|
attributes: {
|
||||||
|
fontSize: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.style.fontSize || null,
|
||||||
|
renderHTML: (attributes) =>
|
||||||
|
attributes.fontSize ? { style: `font-size: ${attributes.fontSize}` } : {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setFontSize:
|
||||||
|
(size) =>
|
||||||
|
({ chain }) =>
|
||||||
|
chain().setMark('textStyle', { fontSize: size }).run(),
|
||||||
|
unsetFontSize:
|
||||||
|
() =>
|
||||||
|
({ chain }) =>
|
||||||
|
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
143
web/src/components/Editor/SearchHighlight.ts
Normal file
143
web/src/components/Editor/SearchHighlight.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
import { mapOffset } from './SuggestionHighlight'
|
||||||
|
|
||||||
|
// SearchHighlight powers the in-document Find & Replace bar. Like the suggestion
|
||||||
|
// layer it uses ProseMirror *decorations* (not stored marks), so matches are
|
||||||
|
// recomputed from the live document on every edit and never leave a stale
|
||||||
|
// highlight behind. Matches are found per-textblock (a query never spans a
|
||||||
|
// paragraph break), mirroring how the rest of the editor anchors text.
|
||||||
|
|
||||||
|
export interface Match {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginState {
|
||||||
|
query: string
|
||||||
|
caseSensitive: boolean
|
||||||
|
matches: Match[]
|
||||||
|
active: number // index into matches, or -1 when there are none
|
||||||
|
decorations: DecorationSet
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchPluginKey = new PluginKey<PluginState>('petalSearch')
|
||||||
|
|
||||||
|
// findMatches collects every occurrence of `query` across the document's
|
||||||
|
// textblocks and maps each to an absolute ProseMirror range.
|
||||||
|
function findMatches(doc: PMNode, query: string, caseSensitive: boolean): Match[] {
|
||||||
|
if (!query) return []
|
||||||
|
const needle = caseSensitive ? query : query.toLowerCase()
|
||||||
|
const matches: Match[] = []
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (!node.isTextblock) return true
|
||||||
|
const haystackRaw = node.textContent
|
||||||
|
const haystack = caseSensitive ? haystackRaw : haystackRaw.toLowerCase()
|
||||||
|
let idx = haystack.indexOf(needle)
|
||||||
|
while (idx >= 0) {
|
||||||
|
matches.push({
|
||||||
|
from: mapOffset(node, pos, idx),
|
||||||
|
to: mapOffset(node, pos, idx + query.length),
|
||||||
|
})
|
||||||
|
idx = haystack.indexOf(needle, idx + query.length)
|
||||||
|
}
|
||||||
|
return false // don't descend into inline children
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(doc: PMNode, query: string, caseSensitive: boolean, preferred: number): PluginState {
|
||||||
|
const matches = findMatches(doc, query, caseSensitive)
|
||||||
|
const active = matches.length === 0 ? -1 : Math.max(0, Math.min(preferred, matches.length - 1))
|
||||||
|
const decos = matches.map((m, i) =>
|
||||||
|
Decoration.inline(m.from, m.to, {
|
||||||
|
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { query, caseSensitive, matches, active, decorations: DecorationSet.create(doc, decos) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: PluginState = {
|
||||||
|
query: '',
|
||||||
|
caseSensitive: false,
|
||||||
|
matches: [],
|
||||||
|
active: -1,
|
||||||
|
decorations: DecorationSet.empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSearch updates the query / case-sensitivity and recomputes matches. Passing
|
||||||
|
// an empty query clears the layer.
|
||||||
|
export function setSearch(
|
||||||
|
state: EditorState,
|
||||||
|
dispatch: (tr: Transaction) => void,
|
||||||
|
query: string,
|
||||||
|
caseSensitive: boolean,
|
||||||
|
) {
|
||||||
|
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'search', query, caseSensitive }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setActive moves the highlighted (current) match by index.
|
||||||
|
export function setActive(state: EditorState, dispatch: (tr: Transaction) => void, index: number) {
|
||||||
|
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'active', index }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSearch tears the layer down (on close).
|
||||||
|
export function clearSearch(state: EditorState, dispatch: (tr: Transaction) => void) {
|
||||||
|
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'clear' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSearchState reads the live plugin state (match list + active index) so the
|
||||||
|
// Find bar can render "n / m" and drive navigation/replacement.
|
||||||
|
export function getSearchState(state: EditorState): PluginState {
|
||||||
|
return searchPluginKey.getState(state) ?? EMPTY
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meta =
|
||||||
|
| { kind: 'search'; query: string; caseSensitive: boolean }
|
||||||
|
| { kind: 'active'; index: number }
|
||||||
|
| { kind: 'clear' }
|
||||||
|
|
||||||
|
export const SearchHighlight = Extension.create({
|
||||||
|
name: 'searchHighlight',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<PluginState>({
|
||||||
|
key: searchPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => EMPTY,
|
||||||
|
apply(tr, value, _oldState, newState): PluginState {
|
||||||
|
const meta = tr.getMeta(searchPluginKey) as Meta | undefined
|
||||||
|
if (meta?.kind === 'search') {
|
||||||
|
return build(newState.doc, meta.query, meta.caseSensitive, value.active < 0 ? 0 : value.active)
|
||||||
|
}
|
||||||
|
if (meta?.kind === 'active') {
|
||||||
|
if (value.matches.length === 0) return value
|
||||||
|
const active = ((meta.index % value.matches.length) + value.matches.length) % value.matches.length
|
||||||
|
const decos = value.matches.map((m, i) =>
|
||||||
|
Decoration.inline(m.from, m.to, {
|
||||||
|
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { ...value, active, decorations: DecorationSet.create(newState.doc, decos) }
|
||||||
|
}
|
||||||
|
if (meta?.kind === 'clear') return EMPTY
|
||||||
|
// Re-anchor on any document change so highlights track edits/replaces.
|
||||||
|
if (tr.docChanged && value.query) {
|
||||||
|
return build(newState.doc, value.query, value.caseSensitive, value.active)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return searchPluginKey.getState(state)?.decorations
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -27,11 +27,14 @@ export const REWRITE_STYLES: RewriteStyle[] = [
|
|||||||
interface Props {
|
interface Props {
|
||||||
style: React.CSSProperties
|
style: React.CSSProperties
|
||||||
onRewrite: (style: string) => void
|
onRewrite: (style: string) => void
|
||||||
|
// Read the selected text aloud (null when speech isn't available — the button
|
||||||
|
// is then hidden).
|
||||||
|
onSpeak: (() => void) | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
|
||||||
export function SelectionBubble({ style, onRewrite }: Props) {
|
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||||
const [natural, ...tones] = REWRITE_STYLES
|
const [natural, ...tones] = REWRITE_STYLES
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,21 +42,25 @@ export function SelectionBubble({ style, onRewrite }: Props) {
|
|||||||
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
|
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label="Rewrite the selection"
|
aria-label="Rewrite the selection"
|
||||||
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-pill)',
|
borderRadius: 'var(--radius-pill)',
|
||||||
boxShadow: 'var(--shadow-soft)',
|
boxShadow: 'var(--shadow-soft)',
|
||||||
fontFamily: CJK,
|
fontFamily: CJK,
|
||||||
|
// Only the buttons capture the pointer; clicks landing on the bubble's
|
||||||
|
// padding/gaps fall through to the text underneath so it never blocks
|
||||||
|
// where the user is trying to click.
|
||||||
|
pointerEvents: 'none',
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
onClick={() => onRewrite(natural.value)}
|
onClick={() => onRewrite(natural.value)}
|
||||||
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||||
title="Rewrite the selection to sound more natural"
|
title="Rewrite the selection to sound more natural"
|
||||||
>
|
>
|
||||||
<span aria-hidden>{natural.emoji}</span>
|
<span aria-hidden>{natural.emoji}</span>
|
||||||
@@ -63,15 +70,30 @@ export function SelectionBubble({ style, onRewrite }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{onSpeak && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
|
onClick={onSpeak}
|
||||||
|
className="inline-flex h-8 items-center justify-center px-2 text-sm"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||||
|
title="朗读所选 · Read selection aloud"
|
||||||
|
aria-label="Read selection aloud"
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
||||||
|
|
||||||
{tones.map((t) => (
|
{tones.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.value}
|
key={t.value}
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
onClick={() => onRewrite(t.value)}
|
onClick={() => onRewrite(t.value)}
|
||||||
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
|
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
||||||
|
|||||||
29
web/src/components/Editor/Typography.ts
Normal file
29
web/src/components/Editor/Typography.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Extension, textInputRule } from '@tiptap/core'
|
||||||
|
|
||||||
|
// Typography adds the small print-shop niceties as you type: curly quotes,
|
||||||
|
// em-dashes, and a single-character ellipsis. Implemented as input rules (no
|
||||||
|
// dependency, offline-friendly — matching FontSize.ts's hand-rolled approach),
|
||||||
|
// and every one is plain Undo-able if it ever fires when you didn't want it.
|
||||||
|
//
|
||||||
|
// CJK is untouched: these rules only rewrite ASCII quotes/dashes/dots, never the
|
||||||
|
// fullwidth forms a Mandarin sentence uses.
|
||||||
|
export const Typography = Extension.create({
|
||||||
|
name: 'petalTypography',
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
// “ opening double quote — after start, whitespace, or an opening bracket.
|
||||||
|
textInputRule({ find: /(?:^|[\s{[(<'"‘“])(")$/, replace: '“' }),
|
||||||
|
// ” closing double quote — any other time you type a ".
|
||||||
|
textInputRule({ find: /"$/, replace: '”' }),
|
||||||
|
// ‘ opening single quote.
|
||||||
|
textInputRule({ find: /(?:^|[\s{[(<'"‘“])(')$/, replace: '‘' }),
|
||||||
|
// ’ closing single quote / apostrophe.
|
||||||
|
textInputRule({ find: /'$/, replace: '’' }),
|
||||||
|
// — em dash from a double hyphen.
|
||||||
|
textInputRule({ find: /--$/, replace: '—' }),
|
||||||
|
// … ellipsis from three dots.
|
||||||
|
textInputRule({ find: /\.\.\.$/, replace: '…' }),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { WordInfo } from '../../api/client'
|
import type { WordInfo } from '../../api/client'
|
||||||
|
import { speak, speechSupported } from '../../audio/speech'
|
||||||
|
|
||||||
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
||||||
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
||||||
@@ -18,6 +19,7 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
|||||||
const definitions = info?.definitions ?? []
|
const definitions = info?.definitions ?? []
|
||||||
const synonyms = info?.synonyms ?? []
|
const synonyms = info?.synonyms ?? []
|
||||||
const gloss = info?.gloss ?? ''
|
const gloss = info?.gloss ?? ''
|
||||||
|
const phonetic = info?.phonetic ?? ''
|
||||||
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,8 +48,28 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
|||||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||||
{word}
|
{word}
|
||||||
</span>
|
</span>
|
||||||
|
{speechSupported() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => speak(word)}
|
||||||
|
aria-label={`Pronounce ${word}`}
|
||||||
|
title="朗读 · Read aloud"
|
||||||
|
className="ml-auto flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* How to say it — the pronunciation aid for an English learner, paired
|
||||||
|
with the 🔊 button above. */}
|
||||||
|
{phonetic && (
|
||||||
|
<p className="mt-1.5 text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
/{phonetic}/
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
|
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
|
||||||
{gloss && (
|
{gloss && (
|
||||||
<p
|
<p
|
||||||
|
|||||||
37
web/src/components/StatusBar/SoundToggle.tsx
Normal file
37
web/src/components/StatusBar/SoundToggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
200
web/src/effects/PetalFall.tsx
Normal file
200
web/src/effects/PetalFall.tsx
Normal 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 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user