Compare commits
9 Commits
0d59b645bb
...
feat/petal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2487e73551 | ||
|
|
0a1ea225dd | ||
|
|
69ecb79da1 | ||
|
|
f3c4fe2c22 | ||
|
|
9e141e4169 | ||
|
|
60eba25fee | ||
|
|
8e1111d768 | ||
|
|
cf7720ea77 | ||
|
|
4c288834c0 |
@@ -74,12 +74,40 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords`→`wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
|
||||
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
|
||||
|
||||
### Phase 8 — Trust foundation (version history + export) ✅
|
||||
- [x] **Version history** — `document_versions` table (migration `0003`), full-body snapshots that cascade with the doc. Kinds: `auto` (throttled background, ≥3min apart, max 40/doc, pruned), `manual` (explicit restore point), `pre_restore` (safety copy taken before a restore, so restore is undoable). Snapshot taken post-save in `update` only when a real body came through and content changed (empties + bare renames never snapshot). Endpoints: `GET/POST /api/docs/:id/versions`, `GET /api/docs/:id/versions/:vid`, `POST /api/docs/:id/versions/:vid/restore`. All scoped to the owner via a join on `documents`. `versions_test.go` covers lifecycle/throttle/restore/pre_restore/404.
|
||||
- [x] **Export** — pure-Go Tiptap-JSON → Markdown / HTML / plain-text / **docx** (`export.go`), no cgo/pandoc, CJK-safe. docx is a hand-built OOXML zip (marks→run props, headings→built-in styles, lists→prefix). RFC 5987 `filename*=UTF-8''` so Chinese titles download cleanly. `GET /api/docs/:id/export?format=`. **PDF is client-side** via the browser print dialog + a `@media print` stylesheet (uses the reader's fonts → CJK for free, no embedded-font bloat). `export_test.go` asserts every format incl. valid-zip docx with CJK.
|
||||
- [x] **Frontend** — `ExportMenu` (download links + Print/PDF) and `HistoryPanel` (slide-over drawer: snapshot list w/ relative-time + kind badge, preview, restore; restore remounts the editor via an `editorEpoch` bump). Both bilingual zh-first, matching chrome. Wired into the title row; `.petal-no-print` strips all chrome for print.
|
||||
- [x] **Stop saving empty docs** — blank `Untitled` drafts now self-discard: `handleCreate` reuses an existing blank instead of stacking another; `openDoc` deletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.)
|
||||
- Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a `pre_restore`, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
|
||||
|
||||
### Phase 9 — ESL superpowers ✅
|
||||
- [x] **Inline Chinese gloss (offline)** — embedded English→Chinese dictionary (`internal/lexicon/data/gloss.json.gz`, ~1.3MB, 57k common words built from ECDICT via `scripts/build_gloss.py`: frequency-gated to rank ≤50k, `[网络]`/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars). `Lexicon` gains a `gloss` map + `Gloss(word)` (same `candidates()` de-inflection as defs/syns); `Result` gains a `Gloss` field. Two surfaces: the right-click **WordCard** now leads with the 中文 gloss, and a new lightweight `GET /api/gloss/{word}` (→ `{word, gloss}`, cached) backs the **hover tooltip**. Offline + instant, works with the LLM down (north-star reliability). Frontend: `GlossTip` (dark pointer-events-none bubble under the resting word; 350ms hover delay; reuses `wordAt` so CJK is never glossed — it's the source language), wired into `EditorCore`'s `onMouseMove`/`onMouseLeave` with a request-token guard, suppressed during selection/preview/other popovers.
|
||||
- [x] **"Say it more naturally" / tone-rewrite** — selecting text pops a `SelectionBubble` (✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored from `ToneSelect`/`styleGuidance`). Picking a style calls `POST /api/docs/:id/rewrite` (`{text, style}` → `{rewrite}`), shown in a `RewritePreview` (original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor via `insertContentAt` over the captured PM range. Backend: `llm.RunRewrite` (one-shot Complete, `RewriteMaxRunes` 2000 cap, `cleanRewrite` strips stray wrapping quotes) + `rewriteSystemTemplate`/`styleGuidance` in `prompts.go`; handler in `internal/suggestions/rewrite.go` (owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). **Stateless** — not persisted as a suggestion; the version history captures the resulting doc change.
|
||||
- Tests: `lexicon` gloss + Lookup-includes-gloss + inflection/miss; `suggestions` rewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss for `river`/inflected/CJK-empty/nonsense-empty, `word/happy` carries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle.
|
||||
- **Known limitation**: `Gloss` tries the literal form first (matching defs/syns ordering), so an inflected word that is *itself* a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g. `rivers` → the proper-noun "Rivers" sense, not `river`). The base form always glosses correctly; acceptable.
|
||||
|
||||
### Phase 10 — Organization & polish ✅
|
||||
- [x] **Cross-document search (FTS5)** — migration `0004` adds a `documents_fts` virtual table over `title` + `content_text` using the **`trigram` tokenizer** (so search works for both English and space-free Chinese; the default unicode61 tokenizer treats a CJK run as one token). Kept in sync by `AFTER INSERT/UPDATE/DELETE` triggers on `documents`, back-filled from existing rows in the migration (verified: pre-existing docs are searchable immediately). `GET /api/search?q=` (`internal/docs/search.go`): queries of ≥3 runes use the FTS index (fast, `ORDER BY rank`); shorter queries fall back to a `LIKE` scan so **2-character Chinese words** (e.g. 公园) still resolve. Snippets are built in **Go** from the original text (clean word boundaries, rune-aware so CJK never splits mid-char), with the match wrapped in `\x01…\x02` sentinels; the client splits on these to highlight without `innerHTML`. Owner-scoped, capped at 50 hits. Frontend `SearchBox` in the sidebar: 220ms-debounced, results with highlighted two-line snippets, click to open.
|
||||
- [x] **Tags (organize)** — migration `0004` adds `tags` (user-scoped, `UNIQUE(user_id, name)`, `color` = palette key) + `document_tags` join (both sides cascade). `internal/docs/tags.go`: `GET/POST/PATCH/DELETE /api/tags` (create is **idempotent** on name; unknown colors coerced to rose) + `POST /api/docs/:id/tags` / `DELETE /api/docs/:id/tags/:tagId` (owner-validated, idempotent assign). The doc-list and search responses carry each doc's tags (loaded in one `tagsByDoc` query, no N+1). Frontend: `useTags` (roster + counts), `TagChip`, `TagPicker` (assign existing / create-and-attach with a color swatch), tag chips on each doc row, a **filter bar** (client-side filter by tag, shows in-use tags with counts). Colors map to the existing design tokens via `tagColorVar`.
|
||||
- [x] **Tablet / touch polish** — responsive sidebar: below 768px it becomes an overlay **drawer** toggled by a header hamburger, with a scrim (auto-closes on doc select). `@media (pointer: coarse)` enlarges tap targets (`.petal-tap` ≥44px, `.petal-tap-sm` ≥36px) and reveals the hover-only row actions (tag/delete). **Tap-to-open** for AI-suggestion cards (no hover on touch): a tap on a `.petal-suggestion` opens its card via the editor click handler, and a `pointerdown` outside the card/highlight dismisses it (mouse users keep the hover bridge).
|
||||
- [x] **Warm LLM-down failure states** — `useCheckpoint` now tracks an `llmDown` flag (set when a check/voice pass hits the server's 502/network path, cleared on the next success or doc switch). The `StatusBar` shows a gentle bilingual note — 🌙 **小助手在休息 · Petal's helper is resting · 文字已保存** — reassuring that the writing still saved locally (saving is independent of the LLM). Rewrite already had a gentle retry from Phase 9.
|
||||
- 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.
|
||||
|
||||
### Deferred (post-v1-local)
|
||||
- [ ] Authentik OIDC auth + session middleware
|
||||
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
|
||||
- [ ] Copyleaks Tier-2 + webhook HMAC
|
||||
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
|
||||
|
||||
### Next-up (post-v1 product, agreed with user 2026-06-26)
|
||||
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
|
||||
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
||||
|
||||
## Session log
|
||||
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
||||
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
|
||||
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
|
||||
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
|
||||
- 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.
|
||||
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/docs"
|
||||
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
||||
"gitea.parodia.dev/drwily/petal/web"
|
||||
@@ -61,12 +62,24 @@ func main() {
|
||||
|
||||
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
||||
// both under /api/docs.
|
||||
docsRouter := docs.New(database).Routes()
|
||||
docsHandler := docs.New(database)
|
||||
docsRouter := docsHandler.Routes()
|
||||
sug.RegisterDocRoutes(docsRouter)
|
||||
api.Mount("/docs", docsRouter)
|
||||
|
||||
// Tag management (the roster) and cross-document full-text search.
|
||||
api.Mount("/tags", docsHandler.TagRoutes())
|
||||
api.Mount("/search", docsHandler.SearchRoutes())
|
||||
|
||||
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||
api.Mount("/suggestions", sug.Routes())
|
||||
|
||||
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
|
||||
// the right-click popover, and the lightweight Chinese-only gloss for the
|
||||
// inline hover/select tooltip. One handler so the datasets load once.
|
||||
lex := lexicon.NewHandler()
|
||||
api.Mount("/word", lex.Routes())
|
||||
api.Mount("/gloss", lex.GlossRoutes())
|
||||
})
|
||||
|
||||
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||
|
||||
@@ -167,6 +167,99 @@ CREATE TABLE plagiarism_reports (
|
||||
|
||||
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
|
||||
// the writer's target register (academic essay vs casual journal).
|
||||
// 'general' means no specific tone steering.
|
||||
name: "0002_document_tone",
|
||||
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
|
||||
},
|
||||
{
|
||||
// Version history: point-in-time snapshots of a document's body so a
|
||||
// bad edit or LLM mishap is always recoverable. `kind` distinguishes
|
||||
// throttled background snapshots ('auto'), explicit user restore
|
||||
// points ('manual'), and the safety copy taken right before a restore
|
||||
// ('pre_restore') so restoring is itself undoable. Snapshots cascade
|
||||
// with the document. Stored fully (content + content_text) so a
|
||||
// restore is a plain copy with no re-derivation.
|
||||
name: "0003_document_versions",
|
||||
stmt: `
|
||||
CREATE TABLE document_versions (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_text TEXT NOT NULL,
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
kind TEXT NOT NULL DEFAULT 'auto'
|
||||
CHECK(kind IN ('auto','manual','pre_restore')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Organization & search (Phase 10). Two parts:
|
||||
//
|
||||
// 1. Tags. A small, user-scoped label set; `color` holds a palette key
|
||||
// (rose/mint/peach/lavender/sky/honey) the frontend maps to CSS.
|
||||
// document_tags is the many-to-many join; both sides cascade so
|
||||
// deleting a doc or a tag cleans up its assignments.
|
||||
//
|
||||
// 2. Full-text search. A standalone FTS5 virtual table over title +
|
||||
// content_text using the `trigram` tokenizer so search works for
|
||||
// both English and space-free Chinese (the default tokenizer treats a
|
||||
// CJK run as one token). It carries an UNINDEXED doc_id to map hits
|
||||
// back to documents, kept in sync by AFTER INSERT/UPDATE/DELETE
|
||||
// triggers, and is back-filled from the existing documents here.
|
||||
// (Trigram needs ≥3 chars to MATCH; the search handler falls back to
|
||||
// LIKE for shorter queries — common for 2-character Chinese words.)
|
||||
name: "0004_tags_and_search",
|
||||
stmt: `
|
||||
CREATE TABLE tags (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT 'rose',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE document_tags (
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (doc_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
||||
|
||||
CREATE VIRTUAL TABLE documents_fts USING fts5(
|
||||
doc_id UNINDEXED,
|
||||
title,
|
||||
content_text,
|
||||
tokenize='trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||
VALUES (new.id, new.title, new.content_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
|
||||
UPDATE documents_fts
|
||||
SET title = new.title, content_text = new.content_text
|
||||
WHERE doc_id = old.id;
|
||||
END;
|
||||
|
||||
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||
SELECT id, title, content_text FROM documents;
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -21,11 +21,59 @@ type Document struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // Tiptap JSON
|
||||
ContentText string `json:"content_text"` // plain text for the LLM
|
||||
Tone string `json:"tone"` // target writing tone; steers LLM advice
|
||||
WordCount int `json:"word_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
|
||||
// a writer can recover from a bad edit or an unwanted change. `Content` mirrors
|
||||
// the document's Tiptap JSON at snapshot time; `Kind` records why it was taken
|
||||
// (see the kind constants). List responses omit the heavy Content/ContentText
|
||||
// fields (the `omitempty`-friendly zero strings) and load them only on preview
|
||||
// or restore.
|
||||
type DocumentVersion struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"doc_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"` // Tiptap JSON; omitted in list view
|
||||
ContentText string `json:"content_text,omitempty"` // plain text; omitted in list view
|
||||
WordCount int `json:"word_count"`
|
||||
Kind string `json:"kind"` // auto | manual | pre_restore
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Document version kinds, mirrored from the schema CHECK constraint.
|
||||
const (
|
||||
VersionKindAuto = "auto" // throttled background snapshot on save
|
||||
VersionKindManual = "manual" // explicit "save a restore point"
|
||||
VersionKindPreRestore = "pre_restore" // safety copy taken just before a restore
|
||||
)
|
||||
|
||||
// Tag is a user-scoped label for organizing documents. `Color` is a palette key
|
||||
// (rose, mint, peach, lavender, sky, honey) the frontend maps to a CSS color;
|
||||
// storing the key (not a hex value) keeps tags in step with the design tokens.
|
||||
// `DocCount` is populated only by the tag-list endpoint (how many documents wear
|
||||
// the tag); it's omitted from per-document tag lists.
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
DocCount int `json:"doc_count,omitempty"`
|
||||
}
|
||||
|
||||
// Tag color palette keys, mirrored on the frontend. Kept small and aligned with
|
||||
// the existing design tokens; unknown values fall back to rose client-side.
|
||||
const (
|
||||
TagColorRose = "rose"
|
||||
TagColorMint = "mint"
|
||||
TagColorPeach = "peach"
|
||||
TagColorLavender = "lavender"
|
||||
TagColorSky = "sky"
|
||||
TagColorHoney = "honey"
|
||||
)
|
||||
|
||||
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
|
||||
//
|
||||
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
||||
|
||||
629
internal/docs/export.go
Normal file
629
internal/docs/export.go
Normal file
@@ -0,0 +1,629 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// exportRoutes registers the download endpoint: GET /api/docs/{id}/export?format=md|html|txt|docx
|
||||
func (h *Handler) exportRoutes(r chi.Router) {
|
||||
r.Get("/{id}/export", h.export)
|
||||
}
|
||||
|
||||
// exportFormat describes one downloadable format: how to render it and how to
|
||||
// label the resulting file.
|
||||
type exportFormat struct {
|
||||
ext string
|
||||
contentType string
|
||||
render func(doc db.Document) ([]byte, error)
|
||||
}
|
||||
|
||||
// exportFormats is the supported set. PDF is intentionally absent: a faithful,
|
||||
// CJK-safe PDF needs an embedded Unicode font (~10MB into the single binary) or
|
||||
// a headless browser (breaks the no-cgo, single-binary story). The frontend
|
||||
// offers "Print / Save as PDF" via the browser instead, which uses the reader's
|
||||
// own fonts and renders CJK correctly for free.
|
||||
var exportFormats = map[string]exportFormat{
|
||||
"md": {ext: "md", contentType: "text/markdown; charset=utf-8", render: renderMarkdown},
|
||||
"html": {ext: "html", contentType: "text/html; charset=utf-8", render: renderHTMLFile},
|
||||
"txt": {ext: "txt", contentType: "text/plain; charset=utf-8", render: renderPlainText},
|
||||
"docx": {
|
||||
ext: "docx",
|
||||
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
render: renderDocx,
|
||||
},
|
||||
}
|
||||
|
||||
// export streams the document in the requested format as a download.
|
||||
func (h *Handler) export(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
|
||||
}
|
||||
|
||||
doc, err := h.fetch(chi.URLParam(r, "id"))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := format.render(doc)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(doc.Title) + "." + format.ext
|
||||
w.Header().Set("Content-Type", format.contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// --- Tiptap document model --------------------------------------------------
|
||||
|
||||
// pmNode is one ProseMirror/Tiptap node. The tree is what the editor stores in
|
||||
// Document.Content; we walk it to render every export format from one source.
|
||||
type pmNode struct {
|
||||
Type string `json:"type"`
|
||||
Attrs map[string]any `json:"attrs"`
|
||||
Marks []pmMark `json:"marks"`
|
||||
Text string `json:"text"`
|
||||
Content []pmNode `json:"content"`
|
||||
}
|
||||
|
||||
type pmMark struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
|
||||
// it falls back to wrapping the plain-text mirror in paragraphs, so export never
|
||||
// fails just because the editor state is unusual.
|
||||
func parseDoc(doc db.Document) pmNode {
|
||||
var root pmNode
|
||||
if err := json.Unmarshal([]byte(doc.Content), &root); err != nil || root.Type == "" {
|
||||
return fallbackDoc(doc.ContentText)
|
||||
}
|
||||
if len(root.Content) == 0 && strings.TrimSpace(doc.ContentText) != "" {
|
||||
return fallbackDoc(doc.ContentText)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// fallbackDoc builds a minimal doc node from plain text, one paragraph per line.
|
||||
func fallbackDoc(text string) pmNode {
|
||||
root := pmNode{Type: "doc"}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
p := pmNode{Type: "paragraph"}
|
||||
if line != "" {
|
||||
p.Content = []pmNode{{Type: "text", Text: line}}
|
||||
}
|
||||
root.Content = append(root.Content, p)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func (n pmNode) hasMark(t string) bool {
|
||||
for _, m := range n.Marks {
|
||||
if m.Type == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (n pmNode) level() int {
|
||||
if n.Attrs == nil {
|
||||
return 1
|
||||
}
|
||||
if l, ok := n.Attrs["level"].(float64); ok && l >= 1 {
|
||||
return int(l)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// --- Markdown ---------------------------------------------------------------
|
||||
|
||||
func renderMarkdown(doc db.Document) ([]byte, error) {
|
||||
root := parseDoc(doc)
|
||||
var blocks []string
|
||||
for _, child := range root.Content {
|
||||
if s := mdBlock(child, 0); s != "" {
|
||||
blocks = append(blocks, s)
|
||||
}
|
||||
}
|
||||
out := "# " + doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
|
||||
return []byte(out), nil
|
||||
}
|
||||
|
||||
func mdBlock(n pmNode, depth int) string {
|
||||
switch n.Type {
|
||||
case "heading":
|
||||
return strings.Repeat("#", n.level()) + " " + mdInline(n.Content)
|
||||
case "paragraph":
|
||||
return mdInline(n.Content)
|
||||
case "blockquote":
|
||||
var lines []string
|
||||
for _, c := range n.Content {
|
||||
for _, l := range strings.Split(mdBlock(c, depth), "\n") {
|
||||
lines = append(lines, "> "+l)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
case "codeBlock":
|
||||
return "```\n" + textContent(n) + "\n```"
|
||||
case "horizontalRule":
|
||||
return "---"
|
||||
case "bulletList", "orderedList":
|
||||
var items []string
|
||||
for i, item := range n.Content {
|
||||
marker := "- "
|
||||
if n.Type == "orderedList" {
|
||||
marker = fmt.Sprintf("%d. ", i+1)
|
||||
}
|
||||
indent := strings.Repeat(" ", depth)
|
||||
// A listItem holds block children (usually one paragraph).
|
||||
var parts []string
|
||||
for _, c := range item.Content {
|
||||
parts = append(parts, mdBlock(c, depth+1))
|
||||
}
|
||||
items = append(items, indent+marker+strings.TrimSpace(strings.Join(parts, "\n")))
|
||||
}
|
||||
return strings.Join(items, "\n")
|
||||
default:
|
||||
// Unknown block: render any inline text it carries.
|
||||
if len(n.Content) > 0 {
|
||||
return mdInline(n.Content)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mdInline(nodes []pmNode) string {
|
||||
var b strings.Builder
|
||||
for _, n := range nodes {
|
||||
switch n.Type {
|
||||
case "text":
|
||||
b.WriteString(applyMdMarks(n))
|
||||
case "hardBreak":
|
||||
b.WriteString(" \n")
|
||||
default:
|
||||
b.WriteString(mdInline(n.Content))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func applyMdMarks(n pmNode) string {
|
||||
t := n.Text
|
||||
if n.hasMark("code") {
|
||||
return "`" + t + "`" // code spans don't combine with other emphasis
|
||||
}
|
||||
if n.hasMark("bold") {
|
||||
t = "**" + t + "**"
|
||||
}
|
||||
if n.hasMark("italic") {
|
||||
t = "*" + t + "*"
|
||||
}
|
||||
if n.hasMark("strike") {
|
||||
t = "~~" + t + "~~"
|
||||
}
|
||||
if n.hasMark("underline") {
|
||||
t = "<u>" + t + "</u>"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// --- Plain text -------------------------------------------------------------
|
||||
|
||||
func renderPlainText(doc db.Document) ([]byte, error) {
|
||||
root := parseDoc(doc)
|
||||
var blocks []string
|
||||
for _, child := range root.Content {
|
||||
if s := txtBlock(child); s != "" {
|
||||
blocks = append(blocks, s)
|
||||
}
|
||||
}
|
||||
out := doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
|
||||
return []byte(out), nil
|
||||
}
|
||||
|
||||
func txtBlock(n pmNode) string {
|
||||
switch n.Type {
|
||||
case "bulletList", "orderedList":
|
||||
var items []string
|
||||
for i, item := range n.Content {
|
||||
marker := "• "
|
||||
if n.Type == "orderedList" {
|
||||
marker = fmt.Sprintf("%d. ", i+1)
|
||||
}
|
||||
items = append(items, marker+strings.TrimSpace(textContent(item)))
|
||||
}
|
||||
return strings.Join(items, "\n")
|
||||
case "horizontalRule":
|
||||
return "----------"
|
||||
default:
|
||||
return textContent(n)
|
||||
}
|
||||
}
|
||||
|
||||
// textContent flattens all descendant text, joining hardBreaks as newlines.
|
||||
func textContent(n pmNode) string {
|
||||
if n.Type == "text" {
|
||||
return n.Text
|
||||
}
|
||||
if n.Type == "hardBreak" {
|
||||
return "\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, c := range n.Content {
|
||||
b.WriteString(textContent(c))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- HTML -------------------------------------------------------------------
|
||||
|
||||
func renderHTMLFile(doc db.Document) ([]byte, error) {
|
||||
root := parseDoc(doc)
|
||||
var body strings.Builder
|
||||
for _, child := range root.Content {
|
||||
body.WriteString(htmlBlock(child))
|
||||
}
|
||||
page := fmt.Sprintf(htmlTemplate, htmlEscape(doc.Title), htmlEscape(doc.Title), body.String())
|
||||
return []byte(page), nil
|
||||
}
|
||||
|
||||
// htmlTemplate is a standalone, self-contained page with a warm, readable
|
||||
// stylesheet and a CJK-first font stack so exported writing looks like Petal,
|
||||
// not a raw dump. No external assets — opens offline anywhere.
|
||||
const htmlTemplate = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
body {
|
||||
font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
|
||||
line-height: 1.75; color: #463a3f; background: #fffafb;
|
||||
max-width: 42rem; margin: 3rem auto; padding: 0 1.5rem;
|
||||
}
|
||||
h1, h2, h3 { font-family: "Georgia", "Songti SC", serif; color: #b04a6a; line-height: 1.3; }
|
||||
h1 { font-size: 1.9rem; border-bottom: 2px solid #f6d6e0; padding-bottom: .4rem; }
|
||||
blockquote { border-left: 3px solid #f3b6c8; margin: 1rem 0; padding: .2rem 1rem; color: #6b5860; background: #fff2f6; }
|
||||
code { background: #fdeef3; padding: .1rem .35rem; border-radius: .3rem; font-size: .9em; }
|
||||
pre { background: #fdeef3; padding: 1rem; border-radius: .6rem; overflow-x: auto; }
|
||||
pre code { background: none; padding: 0; }
|
||||
hr { border: none; border-top: 1px solid #f3cdd9; margin: 2rem 0; }
|
||||
a { color: #b04a6a; }
|
||||
ul, ol { padding-left: 1.4rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>%s</h1>
|
||||
%s</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func htmlBlock(n pmNode) string {
|
||||
switch n.Type {
|
||||
case "heading":
|
||||
tag := fmt.Sprintf("h%d", clampHeading(n.level()))
|
||||
return fmt.Sprintf("<%s>%s</%s>\n", tag, htmlInline(n.Content), tag)
|
||||
case "paragraph":
|
||||
inner := htmlInline(n.Content)
|
||||
if inner == "" {
|
||||
return "<p><br></p>\n"
|
||||
}
|
||||
return "<p>" + inner + "</p>\n"
|
||||
case "blockquote":
|
||||
var b strings.Builder
|
||||
for _, c := range n.Content {
|
||||
b.WriteString(htmlBlock(c))
|
||||
}
|
||||
return "<blockquote>" + b.String() + "</blockquote>\n"
|
||||
case "codeBlock":
|
||||
return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
|
||||
case "horizontalRule":
|
||||
return "<hr>\n"
|
||||
case "bulletList", "orderedList":
|
||||
tag := "ul"
|
||||
if n.Type == "orderedList" {
|
||||
tag = "ol"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<" + tag + ">\n")
|
||||
for _, item := range n.Content {
|
||||
var inner strings.Builder
|
||||
for _, c := range item.Content {
|
||||
// Unwrap a lone paragraph so list items aren't double-spaced.
|
||||
if c.Type == "paragraph" {
|
||||
inner.WriteString(htmlInline(c.Content))
|
||||
} else {
|
||||
inner.WriteString(htmlBlock(c))
|
||||
}
|
||||
}
|
||||
b.WriteString("<li>" + inner.String() + "</li>\n")
|
||||
}
|
||||
b.WriteString("</" + tag + ">\n")
|
||||
return b.String()
|
||||
default:
|
||||
if len(n.Content) > 0 {
|
||||
return "<p>" + htmlInline(n.Content) + "</p>\n"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func htmlInline(nodes []pmNode) string {
|
||||
var b strings.Builder
|
||||
for _, n := range nodes {
|
||||
switch n.Type {
|
||||
case "text":
|
||||
b.WriteString(applyHTMLMarks(n))
|
||||
case "hardBreak":
|
||||
b.WriteString("<br>")
|
||||
default:
|
||||
b.WriteString(htmlInline(n.Content))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func applyHTMLMarks(n pmNode) string {
|
||||
t := htmlEscape(n.Text)
|
||||
if n.hasMark("code") {
|
||||
return "<code>" + t + "</code>"
|
||||
}
|
||||
if n.hasMark("bold") {
|
||||
t = "<strong>" + t + "</strong>"
|
||||
}
|
||||
if n.hasMark("italic") {
|
||||
t = "<em>" + t + "</em>"
|
||||
}
|
||||
if n.hasMark("underline") {
|
||||
t = "<u>" + t + "</u>"
|
||||
}
|
||||
if n.hasMark("strike") {
|
||||
t = "<s>" + t + "</s>"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func clampHeading(level int) int {
|
||||
if level < 1 {
|
||||
return 1
|
||||
}
|
||||
if level > 6 {
|
||||
return 6
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// --- DOCX -------------------------------------------------------------------
|
||||
|
||||
// renderDocx builds a minimal but valid .docx (Office Open XML) in pure Go: a
|
||||
// zip of the few XML parts Word needs. Bold/italic/underline/strike map to run
|
||||
// properties; headings use Word's built-in styles; lists are rendered with a
|
||||
// bullet/number prefix (no numbering.xml dependency). CJK renders with the
|
||||
// reader's own fonts, so no font embedding is required.
|
||||
func renderDocx(doc db.Document) ([]byte, error) {
|
||||
root := parseDoc(doc)
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString(docxHeading(doc.Title, 1))
|
||||
for _, child := range root.Content {
|
||||
body.WriteString(docxBlock(child))
|
||||
}
|
||||
|
||||
document := xmlHeader + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
||||
`<w:body>` + body.String() +
|
||||
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>` +
|
||||
`</w:body></w:document>`
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
parts := []struct{ name, content string }{
|
||||
{"[Content_Types].xml", docxContentTypes},
|
||||
{"_rels/.rels", docxRootRels},
|
||||
{"word/_rels/document.xml.rels", docxDocRels},
|
||||
{"word/document.xml", document},
|
||||
}
|
||||
for _, p := range parts {
|
||||
fw, err := zw.Create(p.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fw.Write([]byte(p.content)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const (
|
||||
xmlHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n"
|
||||
docxContentTypes = xmlHeader + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
|
||||
`<Default Extension="xml" ContentType="application/xml"/>` +
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
|
||||
`</Types>`
|
||||
docxRootRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
|
||||
`</Relationships>`
|
||||
docxDocRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||
)
|
||||
|
||||
func docxBlock(n pmNode) string {
|
||||
switch n.Type {
|
||||
case "heading":
|
||||
return docxHeading(textContent(n), n.level())
|
||||
case "paragraph":
|
||||
return docxPara(n, "")
|
||||
case "blockquote":
|
||||
var b strings.Builder
|
||||
for _, c := range n.Content {
|
||||
b.WriteString(docxPara(c, "Quote"))
|
||||
}
|
||||
return b.String()
|
||||
case "codeBlock":
|
||||
// One paragraph per line preserves layout without a code style.
|
||||
var b strings.Builder
|
||||
for _, line := range strings.Split(textContent(n), "\n") {
|
||||
b.WriteString(`<w:p><w:r><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/></w:rPr>` +
|
||||
`<w:t xml:space="preserve">` + xmlEscape(line) + `</w:t></w:r></w:p>`)
|
||||
}
|
||||
return b.String()
|
||||
case "horizontalRule":
|
||||
return `<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>`
|
||||
case "bulletList", "orderedList":
|
||||
var b strings.Builder
|
||||
for i, item := range n.Content {
|
||||
prefix := "• "
|
||||
if n.Type == "orderedList" {
|
||||
prefix = fmt.Sprintf("%d. ", i+1)
|
||||
}
|
||||
for _, c := range item.Content {
|
||||
b.WriteString(docxPara(c, "", prefix))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
default:
|
||||
if len(n.Content) > 0 {
|
||||
return docxPara(n, "")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// docxPara renders a block's inline children as a Word paragraph. An optional
|
||||
// style name (e.g. "Quote") and an optional literal text prefix (for list
|
||||
// markers) may be supplied.
|
||||
func docxPara(n pmNode, style string, prefix ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("<w:p>")
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="` + style + `"/></w:pPr>`)
|
||||
}
|
||||
if len(prefix) > 0 && prefix[0] != "" {
|
||||
b.WriteString(docxRun(pmNode{Type: "text", Text: prefix[0]}))
|
||||
}
|
||||
b.WriteString(docxInline(n.Content))
|
||||
b.WriteString("</w:p>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func docxHeading(text string, level int) string {
|
||||
return `<w:p><w:pPr><w:pStyle w:val="Heading` + fmt.Sprintf("%d", clampHeading(level)) + `"/></w:pPr>` +
|
||||
docxRun(pmNode{Type: "text", Text: text}) + `</w:p>`
|
||||
}
|
||||
|
||||
func docxInline(nodes []pmNode) string {
|
||||
var b strings.Builder
|
||||
for _, n := range nodes {
|
||||
switch n.Type {
|
||||
case "text":
|
||||
b.WriteString(docxRun(n))
|
||||
case "hardBreak":
|
||||
b.WriteString(`<w:r><w:br/></w:r>`)
|
||||
default:
|
||||
b.WriteString(docxInline(n.Content))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func docxRun(n pmNode) string {
|
||||
var props strings.Builder
|
||||
if n.hasMark("bold") {
|
||||
props.WriteString("<w:b/>")
|
||||
}
|
||||
if n.hasMark("italic") {
|
||||
props.WriteString("<w:i/>")
|
||||
}
|
||||
if n.hasMark("underline") {
|
||||
props.WriteString(`<w:u w:val="single"/>`)
|
||||
}
|
||||
if n.hasMark("strike") {
|
||||
props.WriteString("<w:strike/>")
|
||||
}
|
||||
rpr := ""
|
||||
if props.Len() > 0 {
|
||||
rpr = "<w:rPr>" + props.String() + "</w:rPr>"
|
||||
}
|
||||
return `<w:r>` + rpr + `<w:t xml:space="preserve">` + xmlEscape(n.Text) + `</w:t></w:r>`
|
||||
}
|
||||
|
||||
// --- small helpers ----------------------------------------------------------
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// sanitizeFilename makes a title safe as a download filename, preserving CJK and
|
||||
// most letters while dropping path separators and control characters.
|
||||
func sanitizeFilename(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return "untitled"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range title {
|
||||
switch {
|
||||
case r < 0x20, r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
|
||||
b.WriteRune('-')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
if out == "" {
|
||||
return "untitled"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// urlEscapeFilename percent-encodes a filename for the RFC 5987 filename*=
|
||||
// Content-Disposition form, which carries UTF-8 (CJK titles) safely.
|
||||
func urlEscapeFilename(s string) string {
|
||||
var b strings.Builder
|
||||
for _, c := range []byte(s) {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
135
internal/docs/export_test.go
Normal file
135
internal/docs/export_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// richDocJSON is a Tiptap document exercising headings, marks, and a list —
|
||||
// including CJK text, which every format must carry through intact.
|
||||
const richDocJSON = `{"type":"doc","content":[` +
|
||||
`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"My Section"}]},` +
|
||||
`{"type":"paragraph","content":[{"type":"text","text":"Hello "},{"type":"text","marks":[{"type":"bold"}],"text":"world"},{"type":"text","text":" 你好"}]},` +
|
||||
`{"type":"bulletList","content":[` +
|
||||
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]},` +
|
||||
`{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}` +
|
||||
`]}` +
|
||||
`]}`
|
||||
|
||||
func seedRichDoc(t *testing.T, srv http.Handler) string {
|
||||
t.Helper()
|
||||
id := newDoc(t, srv)
|
||||
body := `{"title":"日记 Diary","content":` + jsonString(richDocJSON) + `,"content_text":"My Section\nHello world 你好\nfirst\nsecond","word_count":6}`
|
||||
if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK {
|
||||
t.Fatalf("seed rich doc: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// jsonString quotes a string as a JSON string literal (so the Tiptap JSON can be
|
||||
// embedded as the "content" field value).
|
||||
func jsonString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestExportMarkdown(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := seedRichDoc(t, srv)
|
||||
|
||||
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("export md: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
|
||||
t.Fatalf("unexpected content-type: %q", ct)
|
||||
}
|
||||
out := rec.Body.String()
|
||||
for _, want := range []string{"## My Section", "**world**", "你好", "- first", "- second"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("markdown missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// CJK filename must survive in the RFC 5987 form.
|
||||
if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "filename*=UTF-8''") {
|
||||
t.Fatalf("expected RFC 5987 filename, got %q", cd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportHTML(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := seedRichDoc(t, srv)
|
||||
|
||||
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "")
|
||||
out := rec.Body.String()
|
||||
for _, want := range []string{"<!doctype html>", "<h2>My Section</h2>", "<strong>world</strong>", "你好", "<li>first</li>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("html missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPlainText(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := seedRichDoc(t, srv)
|
||||
|
||||
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=txt", "")
|
||||
out := rec.Body.String()
|
||||
for _, want := range []string{"My Section", "Hello world 你好", "• first"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("txt missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportDocx(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := seedRichDoc(t, srv)
|
||||
|
||||
rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("export docx: code=%d", rec.Code)
|
||||
}
|
||||
raw := rec.Body.Bytes()
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("docx is not a valid zip: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]bool{"[Content_Types].xml": false, "word/document.xml": false}
|
||||
var docXML string
|
||||
for _, f := range zr.File {
|
||||
if _, ok := want[f.Name]; ok {
|
||||
want[f.Name] = true
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, _ := f.Open()
|
||||
b, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
docXML = string(b)
|
||||
}
|
||||
}
|
||||
for name, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("docx missing part %q", name)
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"My Section", "你好", "<w:b/>", "Heading2"} {
|
||||
if !strings.Contains(docXML, w) {
|
||||
t.Fatalf("document.xml missing %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportUnsupportedFormat(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := newDoc(t, srv)
|
||||
if rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=pdf", ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for unsupported format, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -33,19 +34,25 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.Get("/{id}", h.get)
|
||||
r.Put("/{id}", h.update)
|
||||
r.Delete("/{id}", h.delete)
|
||||
h.versionRoutes(r)
|
||||
h.exportRoutes(r)
|
||||
h.registerTagRoutes(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// docSummary is the lightweight shape returned by the list endpoint — enough to
|
||||
// render the DocList sidebar without shipping every document's full body.
|
||||
// render the DocList sidebar without shipping every document's full body. Tags
|
||||
// ride along so the sidebar can show chips and filter without a second request.
|
||||
type docSummary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
WordCount int `json:"word_count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
WordCount int `json:"word_count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Tags []db.Tag `json:"tags"`
|
||||
}
|
||||
|
||||
// list returns the local user's documents, most-recently-updated first.
|
||||
// list returns the local user's documents, most-recently-updated first, each
|
||||
// decorated with its tags.
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT id, title, word_count, updated_at
|
||||
@@ -61,6 +68,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
defer rows.Close()
|
||||
|
||||
out := []docSummary{} // non-nil so an empty list serializes as [] not null
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var d docSummary
|
||||
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
||||
@@ -68,11 +76,24 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
out = append(out, d)
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
byDoc, err := h.tagsByDoc(ids)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Tags = byDoc[out[i].ID] // nil → JSON null is fine; client treats as none
|
||||
if out[i].Tags == nil {
|
||||
out[i].Tags = []db.Tag{}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -81,11 +102,11 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
var doc db.Document
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO documents (user_id) VALUES (?)
|
||||
RETURNING id, user_id, title, content, content_text, word_count, created_at, updated_at`,
|
||||
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
|
||||
db.LocalUserID,
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -115,6 +136,7 @@ type updateRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
ContentText *string `json:"content_text"`
|
||||
Tone *string `json:"tone"`
|
||||
WordCount *int `json:"word_count"`
|
||||
}
|
||||
|
||||
@@ -134,10 +156,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
SET title = COALESCE(?, title),
|
||||
content = COALESCE(?, content),
|
||||
content_text = COALESCE(?, content_text),
|
||||
tone = COALESCE(?, tone),
|
||||
word_count = COALESCE(?, word_count),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
req.Title, req.Content, req.ContentText, req.WordCount, id, db.LocalUserID,
|
||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -153,6 +176,16 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Capture a throttled history snapshot when a real body save came through
|
||||
// (not a bare rename) and the document has content. Best-effort: a failed
|
||||
// snapshot must never fail the save itself.
|
||||
if req.Content != nil && doc.ContentText != "" {
|
||||
if err := h.maybeAutoSnapshot(doc); err != nil {
|
||||
log.Printf("docs: auto-snapshot for %s failed: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
@@ -177,13 +210,13 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) fetch(id string) (db.Document, error) {
|
||||
var doc db.Document
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT id, user_id, title, content, content_text, word_count, created_at, updated_at
|
||||
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
||||
FROM documents
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
)
|
||||
return doc, err
|
||||
}
|
||||
@@ -206,3 +239,4 @@ func serverError(w http.ResponseWriter, err error) {
|
||||
|
||||
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
|
||||
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }
|
||||
func notFoundMsg(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusNotFound, msg) }
|
||||
|
||||
261
internal/docs/search.go
Normal file
261
internal/docs/search.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// Search snippet shaping.
|
||||
const (
|
||||
// ftsMinRunes is the shortest query the trigram FTS index can match. Shorter
|
||||
// queries (common for 2-character Chinese words) fall back to a LIKE scan.
|
||||
ftsMinRunes = 3
|
||||
|
||||
// snippetContext is how many runes of context to show on each side of the
|
||||
// matched term in a result snippet.
|
||||
snippetContext = 28
|
||||
|
||||
// maxSearchResults caps how many hits we return — plenty for a personal
|
||||
// corpus, and keeps the response small.
|
||||
maxSearchResults = 50
|
||||
|
||||
// hlStart/hlEnd wrap the matched span in a snippet. They're control-character
|
||||
// sentinels that never occur in real text, so the client can split on them to
|
||||
// highlight the match without escaping user content.
|
||||
hlStart = "\x01"
|
||||
hlEnd = "\x02"
|
||||
)
|
||||
|
||||
// searchResult is one hit: a document summary plus a highlighted snippet showing
|
||||
// where the query matched.
|
||||
type searchResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
WordCount int `json:"word_count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Snippet string `json:"snippet"`
|
||||
Tags []db.Tag `json:"tags"`
|
||||
}
|
||||
|
||||
// SearchRoutes returns the router mounted at /api/search.
|
||||
func (h *Handler) SearchRoutes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.search)
|
||||
return r
|
||||
}
|
||||
|
||||
// search runs a cross-document full-text search for the local user. Queries of
|
||||
// three or more runes use the trigram FTS index (fast, ranked); shorter queries
|
||||
// fall back to a LIKE scan so 2-character Chinese words still resolve. Either way
|
||||
// the snippet is built in Go from the original text, for clean word boundaries
|
||||
// and a uniform highlight format.
|
||||
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
writeJSON(w, http.StatusOK, []searchResult{})
|
||||
return
|
||||
}
|
||||
|
||||
type row struct {
|
||||
id, title, contentText, updatedAt string
|
||||
wordCount int
|
||||
}
|
||||
var rows []row
|
||||
|
||||
if utf8.RuneCountInString(q) >= ftsMinRunes {
|
||||
// Wrap the whole query as one FTS phrase (doubling embedded quotes), so
|
||||
// special characters are treated literally and trigram does a contiguous
|
||||
// substring match.
|
||||
phrase := `"` + strings.ReplaceAll(q, `"`, `""`) + `"`
|
||||
sqlRows, err := h.DB.Query(
|
||||
`SELECT d.id, d.title, d.content_text, d.word_count, d.updated_at
|
||||
FROM documents_fts f
|
||||
JOIN documents d ON d.id = f.doc_id
|
||||
WHERE documents_fts MATCH ? AND d.user_id = ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`,
|
||||
phrase, db.LocalUserID, maxSearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
defer sqlRows.Close()
|
||||
for sqlRows.Next() {
|
||||
var rw row
|
||||
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
rows = append(rows, rw)
|
||||
}
|
||||
if err := sqlRows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Short query: LIKE scan over title + body. Escape LIKE wildcards so a
|
||||
// literal % or _ in the query matches itself.
|
||||
like := "%" + escapeLike(q) + "%"
|
||||
sqlRows, err := h.DB.Query(
|
||||
`SELECT id, title, content_text, word_count, updated_at
|
||||
FROM documents
|
||||
WHERE user_id = ?
|
||||
AND (title LIKE ? ESCAPE '\' OR content_text LIKE ? ESCAPE '\')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`,
|
||||
db.LocalUserID, like, like, maxSearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
defer sqlRows.Close()
|
||||
for sqlRows.Next() {
|
||||
var rw row
|
||||
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
rows = append(rows, rw)
|
||||
}
|
||||
if err := sqlRows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]searchResult, 0, len(rows))
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, rw := range rows {
|
||||
out = append(out, searchResult{
|
||||
ID: rw.id,
|
||||
Title: rw.title,
|
||||
WordCount: rw.wordCount,
|
||||
UpdatedAt: rw.updatedAt,
|
||||
Snippet: buildSnippet(rw.title, rw.contentText, q),
|
||||
})
|
||||
ids = append(ids, rw.id)
|
||||
}
|
||||
|
||||
byDoc, err := h.tagsByDoc(ids)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Tags = byDoc[out[i].ID]
|
||||
if out[i].Tags == nil {
|
||||
out[i].Tags = []db.Tag{}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// escapeLike escapes the LIKE metacharacters (% and _) and the escape character
|
||||
// itself so the query is matched literally. Pairs with `ESCAPE '\'`.
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// buildSnippet returns a short excerpt around the first case-insensitive match of
|
||||
// query, with the matched span wrapped in the hl sentinels. It prefers a body
|
||||
// match (with surrounding context); if the query only appears in the title it
|
||||
// highlights the title instead; otherwise it shows the body's opening so a result
|
||||
// always shows something. Windowing is rune-aware so CJK is never split
|
||||
// mid-character.
|
||||
func buildSnippet(title, body, query string) string {
|
||||
runes := []rune(body)
|
||||
qLen := utf8.RuneCountInString(query)
|
||||
if idx := runeIndexFold(runes, query); idx >= 0 {
|
||||
start := idx - snippetContext
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + qLen + snippetContext
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
var b strings.Builder
|
||||
if start > 0 {
|
||||
b.WriteString("…")
|
||||
}
|
||||
b.WriteString(string(runes[start:idx]))
|
||||
b.WriteString(hlStart)
|
||||
b.WriteString(string(runes[idx : idx+qLen]))
|
||||
b.WriteString(hlEnd)
|
||||
b.WriteString(string(runes[idx+qLen : end]))
|
||||
if end < len(runes) {
|
||||
b.WriteString("…")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Body had no literal match (title-only hit, or an FTS span the literal scan
|
||||
// can't reproduce). Highlight the title if the query is there.
|
||||
tRunes := []rune(title)
|
||||
if idx := runeIndexFold(tRunes, query); idx >= 0 {
|
||||
return string(tRunes[:idx]) + hlStart + string(tRunes[idx:idx+qLen]) + hlEnd + string(tRunes[idx+qLen:])
|
||||
}
|
||||
|
||||
// Last resort: the body's opening as plain context.
|
||||
return clip(runes, 0, 2*snippetContext+qLen)
|
||||
}
|
||||
|
||||
// clip returns runes[start:start+n] (bounded), with a trailing ellipsis when the
|
||||
// body continues. Used for the no-direct-match fallback.
|
||||
func clip(runes []rune, start, n int) string {
|
||||
if start >= len(runes) {
|
||||
return ""
|
||||
}
|
||||
end := start + n
|
||||
trailing := ""
|
||||
if end < len(runes) {
|
||||
trailing = "…"
|
||||
} else {
|
||||
end = len(runes)
|
||||
}
|
||||
return string(runes[start:end]) + trailing
|
||||
}
|
||||
|
||||
// runeIndexFold finds the first index (in runes) where query occurs in runes,
|
||||
// case-insensitively. Returns -1 if absent. Simple O(n*m) scan — fine for
|
||||
// single-document snippet building.
|
||||
func runeIndexFold(runes []rune, query string) int {
|
||||
q := []rune(strings.ToLower(query))
|
||||
if len(q) == 0 {
|
||||
return -1
|
||||
}
|
||||
lower := make([]rune, len(runes))
|
||||
for i, r := range runes {
|
||||
lower[i] = toLowerRune(r)
|
||||
}
|
||||
for i := 0; i+len(q) <= len(lower); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(q); j++ {
|
||||
if lower[i+j] != q[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// toLowerRune lowercases ASCII letters (the only case-bearing script here); CJK
|
||||
// and other runes pass through unchanged.
|
||||
func toLowerRune(r rune) rune {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return r + ('a' - 'A')
|
||||
}
|
||||
return r
|
||||
}
|
||||
111
internal/docs/search_test.go
Normal file
111
internal/docs/search_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
srv := newFullServer(t)
|
||||
|
||||
createDoc(t, srv, "My Essay", "The quick brown fox jumps over the lazy dog")
|
||||
createDoc(t, srv, "我的日记", "今天天气很好,我去公园散步,心情非常愉快")
|
||||
createDoc(t, srv, "Notes", "groceries and errands for the weekend")
|
||||
|
||||
search := func(q string) []searchResult {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodGet, "/search?q="+urlQuery(q), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("search %q: %d %s", q, rec.Code, rec.Body)
|
||||
}
|
||||
var out []searchResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode search %q: %v", q, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// English, ≥3 chars → FTS path. Snippet wraps the match in sentinels.
|
||||
res := search("quick")
|
||||
if len(res) != 1 || res[0].Title != "My Essay" {
|
||||
t.Fatalf("english search: %+v", res)
|
||||
}
|
||||
if !strings.Contains(res[0].Snippet, hlStart+"quick"+hlEnd) {
|
||||
t.Fatalf("snippet not highlighted: %q", res[0].Snippet)
|
||||
}
|
||||
|
||||
// Chinese, ≥3 chars → FTS path (trigram handles CJK).
|
||||
res = search("天气很好")
|
||||
if len(res) != 1 || res[0].Title != "我的日记" {
|
||||
t.Fatalf("chinese fts search: %+v", res)
|
||||
}
|
||||
if !strings.Contains(res[0].Snippet, hlStart+"天气很好"+hlEnd) {
|
||||
t.Fatalf("cjk snippet not highlighted: %q", res[0].Snippet)
|
||||
}
|
||||
|
||||
// 2-character Chinese word → LIKE fallback (below the trigram minimum).
|
||||
res = search("公园")
|
||||
if len(res) != 1 || res[0].Title != "我的日记" {
|
||||
t.Fatalf("cjk short (LIKE) search: %+v", res)
|
||||
}
|
||||
if !strings.Contains(res[0].Snippet, hlStart+"公园"+hlEnd) {
|
||||
t.Fatalf("short cjk snippet not highlighted: %q", res[0].Snippet)
|
||||
}
|
||||
|
||||
// Title-only match still returns the doc, highlighting the title.
|
||||
res = search("Notes")
|
||||
if len(res) != 1 || res[0].Title != "Notes" {
|
||||
t.Fatalf("title search: %+v", res)
|
||||
}
|
||||
|
||||
// Case-insensitive.
|
||||
if got := search("QUICK"); len(got) != 1 {
|
||||
t.Fatalf("case-insensitive search: %+v", got)
|
||||
}
|
||||
|
||||
// No match → empty (non-nil) array.
|
||||
if got := search("zzzznotthere"); len(got) != 0 {
|
||||
t.Fatalf("expected no results, got %+v", got)
|
||||
}
|
||||
|
||||
// Empty query → empty array, no error.
|
||||
rec := do(t, srv, http.MethodGet, "/search?q=", "")
|
||||
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" {
|
||||
t.Fatalf("empty query: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// An edit re-indexes via the update trigger: the old term stops matching, the
|
||||
// new term starts.
|
||||
res = search("quick")
|
||||
docID := res[0].ID
|
||||
body := `{"content":"{}","content_text":"the slow purple turtle ambles along","word_count":6}`
|
||||
do(t, srv, http.MethodPut, "/docs/"+docID, body)
|
||||
if got := search("quick"); len(got) != 0 {
|
||||
t.Fatalf("stale term still matches after edit: %+v", got)
|
||||
}
|
||||
if got := search("purple turtle"); len(got) != 1 || got[0].ID != docID {
|
||||
t.Fatalf("new term not indexed after edit: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// urlQuery percent-encodes a search term for the query string.
|
||||
func urlQuery(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range []byte(s) {
|
||||
if r == ' ' {
|
||||
b.WriteByte('+')
|
||||
} else if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteByte(r)
|
||||
} else {
|
||||
b.WriteString(percentByte(r))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func percentByte(b byte) string {
|
||||
const hex = "0123456789ABCDEF"
|
||||
return string([]byte{'%', hex[b>>4], hex[b&0xf]})
|
||||
}
|
||||
303
internal/docs/tags.go
Normal file
303
internal/docs/tags.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// validTagColors is the palette a tag may use, mirrored from the design tokens.
|
||||
// An unknown color on input is coerced to rose so the UI always has a token.
|
||||
var validTagColors = map[string]bool{
|
||||
db.TagColorRose: true, db.TagColorMint: true, db.TagColorPeach: true,
|
||||
db.TagColorLavender: true, db.TagColorSky: true, db.TagColorHoney: true,
|
||||
}
|
||||
|
||||
func normalizeColor(c string) string {
|
||||
if validTagColors[c] {
|
||||
return c
|
||||
}
|
||||
return db.TagColorRose
|
||||
}
|
||||
|
||||
// TagRoutes returns the router mounted at /api/tags for tag management (the
|
||||
// roster the writer creates and recolors), separate from the per-document
|
||||
// assignment routes registered on the docs router.
|
||||
func (h *Handler) TagRoutes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.listTags)
|
||||
r.Post("/", h.createTag)
|
||||
r.Patch("/{id}", h.updateTag)
|
||||
r.Delete("/{id}", h.deleteTag)
|
||||
return r
|
||||
}
|
||||
|
||||
// registerTagRoutes adds the per-document tag assignment routes onto the docs
|
||||
// sub-router so they resolve under /api/docs/{id}/tags.
|
||||
func (h *Handler) registerTagRoutes(r chi.Router) {
|
||||
r.Post("/{id}/tags", h.assignTag)
|
||||
r.Delete("/{id}/tags/{tagId}", h.unassignTag)
|
||||
}
|
||||
|
||||
// listTags returns the user's tags alphabetically, each with the number of
|
||||
// documents that wear it (so the sidebar can show counts and hide empties later
|
||||
// if desired).
|
||||
func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT t.id, t.name, t.color, COUNT(dt.doc_id)
|
||||
FROM tags t
|
||||
LEFT JOIN document_tags dt ON dt.tag_id = t.id
|
||||
WHERE t.user_id = ?
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []db.Tag{} // non-nil so an empty roster serializes as []
|
||||
for rows.Next() {
|
||||
var t db.Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type tagRequest struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// createTag adds a tag. It's idempotent on (user, name): re-creating an existing
|
||||
// tag returns it unchanged rather than erroring, so the client can "create or
|
||||
// reuse" in one call. Recoloring is done via updateTag.
|
||||
func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
||||
var req tagRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badRequest(w, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, "tag name is required")
|
||||
return
|
||||
}
|
||||
|
||||
var t db.Tag
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, name) DO UPDATE SET name = excluded.name
|
||||
RETURNING id, name, color`,
|
||||
db.LocalUserID, name, normalizeColor(req.Color),
|
||||
).Scan(&t.ID, &t.Name, &t.Color)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// updateTag renames and/or recolors a tag. Both fields optional via pointers so
|
||||
// a recolor needn't resend the name.
|
||||
func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badRequest(w, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
var namePtr, colorPtr any
|
||||
if req.Name != nil {
|
||||
n := strings.TrimSpace(*req.Name)
|
||||
if n == "" {
|
||||
badRequest(w, "tag name cannot be empty")
|
||||
return
|
||||
}
|
||||
namePtr = n
|
||||
}
|
||||
if req.Color != nil {
|
||||
colorPtr = normalizeColor(*req.Color)
|
||||
}
|
||||
|
||||
res, err := h.DB.Exec(
|
||||
`UPDATE tags
|
||||
SET name = COALESCE(?, name),
|
||||
color = COALESCE(?, color)
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
namePtr, colorPtr, id, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
notFoundMsg(w, "tag not found")
|
||||
return
|
||||
}
|
||||
|
||||
var t db.Tag
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// deleteTag removes a tag; its document assignments cascade away via the FK.
|
||||
func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.DB.Exec(
|
||||
`DELETE FROM tags WHERE id = ? AND user_id = ?`,
|
||||
chi.URLParam(r, "id"), db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
notFoundMsg(w, "tag not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// assignTag attaches a tag to a document. Both must belong to the local user;
|
||||
// the assignment is idempotent (re-assigning is a no-op, not an error).
|
||||
func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
TagID string `json:"tag_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badRequest(w, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.TagID == "" {
|
||||
badRequest(w, "tag_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify both the doc and the tag belong to the user before linking, so a
|
||||
// stray id can't cross-link another account's rows.
|
||||
if !h.ownsDoc(docID) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if !h.ownsTag(req.TagID) {
|
||||
notFoundMsg(w, "tag not found")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.DB.Exec(
|
||||
`INSERT INTO document_tags (doc_id, tag_id) VALUES (?, ?)
|
||||
ON CONFLICT(doc_id, tag_id) DO NOTHING`,
|
||||
docID, req.TagID,
|
||||
); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// unassignTag detaches a tag from a document.
|
||||
func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
tagID := chi.URLParam(r, "tagId")
|
||||
|
||||
if !h.ownsDoc(docID) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if _, err := h.DB.Exec(
|
||||
`DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`,
|
||||
docID, tagID,
|
||||
); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ownsDoc reports whether a document belongs to the local user.
|
||||
func (h *Handler) ownsDoc(docID string) bool {
|
||||
var exists bool
|
||||
_ = h.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
|
||||
// ownsTag reports whether a tag belongs to the local user.
|
||||
func (h *Handler) ownsTag(tagID string) bool {
|
||||
var exists bool
|
||||
_ = h.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = ? AND user_id = ?)`,
|
||||
tagID, db.LocalUserID,
|
||||
).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
|
||||
// tagsByDoc loads the tags for a set of documents in one query and groups them
|
||||
// by doc id. Used to decorate the document list and search results without an
|
||||
// N+1 of per-doc queries. Returns an empty (non-nil) map when ids is empty.
|
||||
func (h *Handler) tagsByDoc(ids []string) (map[string][]db.Tag, error) {
|
||||
out := map[string][]db.Tag{}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Build the IN (?, ?, …) placeholder list.
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, db.LocalUserID)
|
||||
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT dt.doc_id, t.id, t.name, t.color
|
||||
FROM document_tags dt
|
||||
JOIN tags t ON t.id = dt.tag_id
|
||||
WHERE dt.doc_id IN (`+ph+`) AND t.user_id = ?
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var docID string
|
||||
var t db.Tag
|
||||
if err := rows.Scan(&docID, &t.ID, &t.Name, &t.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[docID] = append(out[docID], t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
167
internal/docs/tags_test.go
Normal file
167
internal/docs/tags_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// newFullServer mounts the document, tag, and search routers under the same base
|
||||
// paths as the real server (/docs, /tags, /search) so tests can exercise the
|
||||
// cross-router flows (create a doc, tag it, list, search).
|
||||
func newFullServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
h := New(database)
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/docs", h.Routes())
|
||||
r.Mount("/tags", h.TagRoutes())
|
||||
r.Mount("/search", h.SearchRoutes())
|
||||
return r
|
||||
}
|
||||
|
||||
// createDoc makes a document with the given title/body and returns its id.
|
||||
func createDoc(t *testing.T, srv http.Handler, title, text string) string {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodPost, "/docs", "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create doc: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var d db.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &d)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"title": title, "content": "{}", "content_text": text, "word_count": 1,
|
||||
})
|
||||
rec = do(t, srv, http.MethodPut, "/docs/"+d.ID, string(body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save doc: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
func TestTagLifecycle(t *testing.T) {
|
||||
srv := newFullServer(t)
|
||||
|
||||
// Empty roster serializes as [].
|
||||
rec := do(t, srv, http.MethodGet, "/tags", "")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() == "null\n" {
|
||||
t.Fatalf("empty tags: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// Create a tag.
|
||||
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"lavender"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create tag: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var tag db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tag)
|
||||
if tag.ID == "" || tag.Name != "日记" || tag.Color != "lavender" {
|
||||
t.Fatalf("bad created tag: %+v", tag)
|
||||
}
|
||||
|
||||
// Re-creating the same name is idempotent (returns the same id, keeps color).
|
||||
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"sky"}`)
|
||||
var again db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||
if again.ID != tag.ID || again.Color != "lavender" {
|
||||
t.Fatalf("create not idempotent: %+v vs %+v", again, tag)
|
||||
}
|
||||
|
||||
// Unknown color is coerced to rose.
|
||||
rec = do(t, srv, http.MethodPost, "/tags", `{"name":"work","color":"chartreuse"}`)
|
||||
var work db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &work)
|
||||
if work.Color != "rose" {
|
||||
t.Fatalf("unknown color not coerced: %+v", work)
|
||||
}
|
||||
|
||||
// Empty name is rejected.
|
||||
if rec = do(t, srv, http.MethodPost, "/tags", `{"name":" "}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty name should be 400: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Recolor + rename via PATCH.
|
||||
rec = do(t, srv, http.MethodPatch, "/tags/"+tag.ID, `{"color":"honey"}`)
|
||||
var recolored db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &recolored)
|
||||
if recolored.Color != "honey" || recolored.Name != "日记" {
|
||||
t.Fatalf("patch clobbered/failed: %+v", recolored)
|
||||
}
|
||||
|
||||
// Assign both tags to a document.
|
||||
docID := createDoc(t, srv, "My Journal", "today was a good day")
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("assign tag: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
// Re-assigning is idempotent.
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("re-assign tag: %d", rec.Code)
|
||||
}
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("assign 2nd tag: %d", rec.Code)
|
||||
}
|
||||
|
||||
// The doc list now carries both tags, ordered by name.
|
||||
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||
var docs []docSummary
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||
if len(docs) != 1 || len(docs[0].Tags) != 2 {
|
||||
t.Fatalf("doc list tags: %+v", docs)
|
||||
}
|
||||
|
||||
// Tag list reports doc counts.
|
||||
rec = do(t, srv, http.MethodGet, "/tags", "")
|
||||
var roster []db.Tag
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &roster)
|
||||
var found bool
|
||||
for _, rt := range roster {
|
||||
if rt.ID == tag.ID {
|
||||
found = true
|
||||
if rt.DocCount != 1 {
|
||||
t.Fatalf("expected doc_count 1, got %d", rt.DocCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tag missing from roster: %+v", roster)
|
||||
}
|
||||
|
||||
// Unassign one tag.
|
||||
if rec = do(t, srv, http.MethodDelete, "/docs/"+docID+"/tags/"+work.ID, ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unassign: %d", rec.Code)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||
if len(docs[0].Tags) != 1 || docs[0].Tags[0].ID != tag.ID {
|
||||
t.Fatalf("after unassign: %+v", docs[0].Tags)
|
||||
}
|
||||
|
||||
// Deleting a tag cascades its assignments away.
|
||||
if rec = do(t, srv, http.MethodDelete, "/tags/"+tag.ID, ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete tag: %d", rec.Code)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/docs", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docs)
|
||||
if len(docs[0].Tags) != 0 {
|
||||
t.Fatalf("tag delete did not cascade: %+v", docs[0].Tags)
|
||||
}
|
||||
|
||||
// Assigning a non-existent tag 404s; tagging a non-existent doc 404s.
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"nope"}`); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("assign unknown tag: %d", rec.Code)
|
||||
}
|
||||
if rec = do(t, srv, http.MethodPost, "/docs/nope/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("assign to unknown doc: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
245
internal/docs/versions.go
Normal file
245
internal/docs/versions.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// Version-history tuning.
|
||||
const (
|
||||
// autoSnapshotInterval is the minimum gap between background ('auto')
|
||||
// snapshots. Auto-save fires every ~1.5s; without a floor we'd store a
|
||||
// version per keystroke-burst. A few minutes keeps history useful for
|
||||
// recovery without unbounded growth.
|
||||
autoSnapshotInterval = 3 * time.Minute
|
||||
|
||||
// maxAutoVersions caps how many 'auto' snapshots we retain per document.
|
||||
// 'manual' and 'pre_restore' versions are never pruned — they're explicit
|
||||
// restore points the writer (or a restore) deliberately created.
|
||||
maxAutoVersions = 40
|
||||
)
|
||||
|
||||
// versionRoutes registers the history endpoints on the docs sub-router. Paths
|
||||
// resolve to /api/docs/{id}/versions...
|
||||
func (h *Handler) versionRoutes(r chi.Router) {
|
||||
r.Get("/{id}/versions", h.listVersions)
|
||||
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
||||
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
||||
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
|
||||
}
|
||||
|
||||
// listVersions returns the document's snapshots, newest first, without the heavy
|
||||
// content fields (those load on preview/restore).
|
||||
func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
// Scope through the documents table so a snapshot is only visible to the
|
||||
// owner of its parent document.
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT v.id, v.doc_id, v.title, v.word_count, v.kind, v.created_at
|
||||
FROM document_versions v
|
||||
JOIN documents d ON d.id = v.doc_id
|
||||
WHERE v.doc_id = ? AND d.user_id = ?
|
||||
ORDER BY v.created_at DESC`,
|
||||
docID, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []db.DocumentVersion{} // non-nil so an empty history serializes as []
|
||||
for rows.Next() {
|
||||
var v db.DocumentVersion
|
||||
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// getVersion returns one snapshot in full (including content) for preview.
|
||||
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
v, err := h.fetchVersion(chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFoundMsg(w, "version not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
// createVersion takes an explicit, user-requested ('manual') restore point from
|
||||
// the document's current saved state.
|
||||
func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
doc, err := h.fetch(docID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
v, err := h.insertVersion(doc, db.VersionKindManual)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, v)
|
||||
}
|
||||
|
||||
// restoreVersion copies a snapshot back onto the live document. Before
|
||||
// overwriting, it captures the current state as a 'pre_restore' version so the
|
||||
// restore is itself undoable. Returns the restored document.
|
||||
func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
vid := chi.URLParam(r, "vid")
|
||||
|
||||
v, err := h.fetchVersion(docID, vid)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFoundMsg(w, "version not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
current, err := h.fetch(docID)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.DB.Exec(
|
||||
`UPDATE documents
|
||||
SET title = ?, content = ?, content_text = ?, word_count = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.fetch(docID)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// maybeAutoSnapshot records a throttled background snapshot of the just-saved
|
||||
// document. It no-ops when the newest snapshot is younger than
|
||||
// autoSnapshotInterval, or when the body is unchanged since the last snapshot,
|
||||
// so an idle or rename-only save costs nothing. Best-effort: callers log but do
|
||||
// not fail the save if this errors. Prunes old 'auto' versions on success.
|
||||
func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
|
||||
var (
|
||||
lastAt time.Time
|
||||
lastText string
|
||||
hasPrev bool
|
||||
)
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT created_at, content_text FROM document_versions
|
||||
WHERE doc_id = ? ORDER BY created_at DESC LIMIT 1`,
|
||||
doc.ID,
|
||||
).Scan(&lastAt, &lastText)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
hasPrev = false
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
hasPrev = true
|
||||
}
|
||||
|
||||
if hasPrev {
|
||||
if lastText == doc.ContentText {
|
||||
return nil // nothing meaningful changed
|
||||
}
|
||||
if time.Since(lastAt) < autoSnapshotInterval {
|
||||
return nil // too soon; let edits accumulate
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := h.insertVersion(doc, db.VersionKindAuto); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.pruneAutoVersions(doc.ID)
|
||||
}
|
||||
|
||||
// insertVersion writes a snapshot row of the given kind and returns it (without
|
||||
// the heavy content fields, matching the list shape).
|
||||
func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
|
||||
var v db.DocumentVersion
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, doc_id, title, word_count, kind, created_at`,
|
||||
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind,
|
||||
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// pruneAutoVersions trims a document's 'auto' snapshots to the newest
|
||||
// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
|
||||
func (h *Handler) pruneAutoVersions(docID string) error {
|
||||
_, err := h.DB.Exec(
|
||||
`DELETE FROM document_versions
|
||||
WHERE doc_id = ? AND kind = 'auto'
|
||||
AND id NOT IN (
|
||||
SELECT id FROM document_versions
|
||||
WHERE doc_id = ? AND kind = 'auto'
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
)`,
|
||||
docID, docID, maxAutoVersions,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// fetchVersion loads one full snapshot, scoped to its owner via the parent doc.
|
||||
func (h *Handler) fetchVersion(docID, vid string) (db.DocumentVersion, error) {
|
||||
var v db.DocumentVersion
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT v.id, v.doc_id, v.title, v.content, v.content_text, v.word_count, v.kind, v.created_at
|
||||
FROM document_versions v
|
||||
JOIN documents d ON d.id = v.doc_id
|
||||
WHERE v.id = ? AND v.doc_id = ? AND d.user_id = ?`,
|
||||
vid, docID, db.LocalUserID,
|
||||
).Scan(
|
||||
&v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText,
|
||||
&v.WordCount, &v.Kind, &v.CreatedAt,
|
||||
)
|
||||
return v, err
|
||||
}
|
||||
107
internal/docs/versions_test.go
Normal file
107
internal/docs/versions_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// newDoc creates a document and returns its id.
|
||||
func newDoc(t *testing.T, srv http.Handler) string {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodPost, "/", "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create doc: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var d db.Document
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &d); err != nil {
|
||||
t.Fatalf("decode doc: %v", err)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
func listVersions(t *testing.T, srv http.Handler, id string) []db.DocumentVersion {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodGet, "/"+id+"/versions", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list versions: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var vs []db.DocumentVersion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &vs); err != nil {
|
||||
t.Fatalf("decode versions: %v", err)
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
func TestVersionLifecycle(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := newDoc(t, srv)
|
||||
|
||||
// A bare rename (no content) must not snapshot.
|
||||
do(t, srv, http.MethodPut, "/"+id, `{"title":"Just A Name"}`)
|
||||
if vs := listVersions(t, srv, id); len(vs) != 0 {
|
||||
t.Fatalf("rename should not snapshot, got %d", len(vs))
|
||||
}
|
||||
|
||||
// First real body save takes one auto snapshot (no prior version to throttle).
|
||||
body := `{"content":"{\"type\":\"doc\"}","content_text":"first draft","word_count":2}`
|
||||
do(t, srv, http.MethodPut, "/"+id, body)
|
||||
vs := listVersions(t, srv, id)
|
||||
if len(vs) != 1 || vs[0].Kind != db.VersionKindAuto {
|
||||
t.Fatalf("expected 1 auto version, got %+v", vs)
|
||||
}
|
||||
// List view omits heavy content fields.
|
||||
if vs[0].Content != "" {
|
||||
t.Fatalf("list should omit content, got %q", vs[0].Content)
|
||||
}
|
||||
|
||||
// A near-immediate second save is throttled (no new auto version).
|
||||
do(t, srv, http.MethodPut, "/"+id, `{"content":"{\"type\":\"doc\"}","content_text":"first draft v2","word_count":3}`)
|
||||
if vs := listVersions(t, srv, id); len(vs) != 1 {
|
||||
t.Fatalf("second save within interval should be throttled, got %d", len(vs))
|
||||
}
|
||||
|
||||
// Manual snapshot always records, capturing current saved state.
|
||||
rec := do(t, srv, http.MethodPost, "/"+id+"/versions", "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("manual snapshot: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var manual db.DocumentVersion
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &manual)
|
||||
if manual.Kind != db.VersionKindManual {
|
||||
t.Fatalf("expected manual kind, got %q", manual.Kind)
|
||||
}
|
||||
|
||||
// Mutate the live doc, then restore the manual snapshot.
|
||||
do(t, srv, http.MethodPut, "/"+id, `{"content":"{\"type\":\"doc\"}","content_text":"ruined everything","word_count":2}`)
|
||||
rec = do(t, srv, http.MethodPost, "/"+id+"/versions/"+manual.ID+"/restore", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var restored db.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &restored)
|
||||
if restored.ContentText != "first draft v2" {
|
||||
t.Fatalf("restore did not bring back snapshot text: %q", restored.ContentText)
|
||||
}
|
||||
|
||||
// Restore left a pre_restore safety copy, so the bad state is itself recoverable.
|
||||
var foundPreRestore bool
|
||||
for _, v := range listVersions(t, srv, id) {
|
||||
if v.Kind == db.VersionKindPreRestore {
|
||||
foundPreRestore = true
|
||||
}
|
||||
}
|
||||
if !foundPreRestore {
|
||||
t.Fatal("restore should record a pre_restore safety snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionNotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := newDoc(t, srv)
|
||||
if rec := do(t, srv, http.MethodGet, "/"+id+"/versions/nope", ""); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for unknown version, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
29
internal/lexicon/data.go
Normal file
29
internal/lexicon/data.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Package lexicon serves offline word lookups — a Chinese gloss, a definition,
|
||||
// and a list of synonyms for a single word — from public datasets compiled into
|
||||
// the binary. Definitions come from the Wordset dictionary (modern, concise
|
||||
// glosses with a part of speech and example, which read kindly for an ESL
|
||||
// writer); synonyms come from the Moby Thesaurus; the Chinese gloss comes from
|
||||
// ECDICT. All are gzipped JSON, decompressed lazily on first use so a writer who
|
||||
// never looks up a word pays nothing.
|
||||
package lexicon
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// definitionsGz is the gzipped Wordset definitions map: word → [[pos, def,
|
||||
// example], …], lowercase keys. Built by the data-prep step (see commit notes).
|
||||
//
|
||||
//go:embed data/definitions.json.gz
|
||||
var definitionsGz []byte
|
||||
|
||||
// synonymsGz is the gzipped Moby thesaurus map: headword → [synonym, …],
|
||||
// lowercase keys, capped per word to keep the popover (and the binary) small.
|
||||
//
|
||||
//go:embed data/synonyms.json.gz
|
||||
var synonymsGz []byte
|
||||
|
||||
// glossGz is the gzipped English→Chinese gloss map: word → 中文 gloss, lowercase
|
||||
// keys. Built from ECDICT (scripts/build_gloss.py), filtered to common words so
|
||||
// an ESL writer who speaks Mandarin gets an instant translation on hover/lookup.
|
||||
//
|
||||
//go:embed data/gloss.json.gz
|
||||
var glossGz []byte
|
||||
BIN
internal/lexicon/data/definitions.json.gz
Normal file
BIN
internal/lexicon/data/definitions.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/gloss.json.gz
Normal file
BIN
internal/lexicon/data/gloss.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
Binary file not shown.
81
internal/lexicon/handlers.go
Normal file
81
internal/lexicon/handlers.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler serves the word-lookup endpoint backed by a single shared Lexicon.
|
||||
type Handler struct {
|
||||
Lex *Lexicon
|
||||
}
|
||||
|
||||
// New constructs a Handler with a fresh (lazily-loaded) Lexicon.
|
||||
func NewHandler() *Handler { return &Handler{Lex: New()} }
|
||||
|
||||
// Routes returns the router mounted at /api/word. The word is a path segment so
|
||||
// "/api/word/happy" reads naturally; it's URL-decoded to tolerate the rare
|
||||
// punctuated token.
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/{word}", h.lookup)
|
||||
return r
|
||||
}
|
||||
|
||||
// GlossRoutes returns the router mounted at /api/gloss — the lightweight
|
||||
// Chinese-only lookup behind the inline hover/select gloss. It shares the
|
||||
// Handler's Lexicon, so the datasets still load just once.
|
||||
func (h *Handler) GlossRoutes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/{word}", h.gloss)
|
||||
return r
|
||||
}
|
||||
|
||||
// lookup returns the definition + synonyms for one word. A word found in neither
|
||||
// dataset still returns 200 with empty lists, so the popover can show a friendly
|
||||
// "nothing found" rather than an error state.
|
||||
func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
|
||||
word := chi.URLParam(r, "word")
|
||||
if decoded, err := url.PathUnescape(word); err == nil {
|
||||
word = decoded
|
||||
}
|
||||
|
||||
res, err := h.Lex.Lookup(word)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Word lookups are static for the life of the build; let the browser cache
|
||||
// them so repeated right-clicks on the same word are instant.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
// gloss returns just the Chinese translation for one word. Like lookup, a miss
|
||||
// is a 200 with an empty gloss so the hover tooltip can quietly skip rather than
|
||||
// error.
|
||||
func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) {
|
||||
word := chi.URLParam(r, "word")
|
||||
if decoded, err := url.PathUnescape(word); err == nil {
|
||||
word = decoded
|
||||
}
|
||||
|
||||
res, err := h.Lex.Gloss(word)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
249
internal/lexicon/lexicon.go
Normal file
249
internal/lexicon/lexicon.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Meaning is one sense of a word: its part of speech, the gloss, and an optional
|
||||
// usage example. The frontend renders a few of these in the word popover.
|
||||
type Meaning struct {
|
||||
PartOfSpeech string `json:"part_of_speech"`
|
||||
Definition string `json:"definition"`
|
||||
Example string `json:"example,omitempty"`
|
||||
}
|
||||
|
||||
// Result is the full lookup for one word. Either list may be empty (the word
|
||||
// isn't a headword in that dataset); the frontend handles a partial or empty
|
||||
// result gracefully. Gloss is the Chinese translation (empty when the word isn't
|
||||
// in the gloss dataset) — shown first in the popover for the Mandarin-speaking
|
||||
// writer.
|
||||
type Result struct {
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
Definitions []Meaning `json:"definitions"`
|
||||
Synonyms []string `json:"synonyms"`
|
||||
}
|
||||
|
||||
// GlossResult is the lightweight payload for the inline hover/select gloss: just
|
||||
// the word and its Chinese translation, no definitions or synonyms. Kept small
|
||||
// so the hover tooltip is instant and trivially cacheable.
|
||||
type GlossResult struct {
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
}
|
||||
|
||||
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
|
||||
// stores up to ~50 per word — a long flat wall of words overwhelms more than it
|
||||
// helps, especially for an ESL reader scanning for the right fit.
|
||||
const maxSynonyms = 16
|
||||
|
||||
// maxDefinitions caps the senses shown so the popover stays a glance, not an
|
||||
// essay.
|
||||
const maxDefinitions = 4
|
||||
|
||||
// Lexicon holds the lazily-loaded datasets. The maps are populated once, on the
|
||||
// first Lookup, behind a sync.Once so startup stays instant and a load error is
|
||||
// remembered rather than retried on every request.
|
||||
type Lexicon struct {
|
||||
once sync.Once
|
||||
loadErr error
|
||||
defs map[string][][]string // word → [[pos, def, example], …]
|
||||
synonyms map[string][]string // word → [synonym, …]
|
||||
gloss map[string]string // word → Chinese gloss
|
||||
}
|
||||
|
||||
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
||||
func New() *Lexicon { return &Lexicon{} }
|
||||
|
||||
func (l *Lexicon) load() {
|
||||
l.once.Do(func() {
|
||||
if err := gunzipJSON(definitionsGz, &l.defs); err != nil {
|
||||
l.loadErr = fmt.Errorf("load definitions: %w", err)
|
||||
return
|
||||
}
|
||||
if err := gunzipJSON(synonymsGz, &l.synonyms); err != nil {
|
||||
l.loadErr = fmt.Errorf("load synonyms: %w", err)
|
||||
return
|
||||
}
|
||||
if err := gunzipJSON(glossGz, &l.gloss); err != nil {
|
||||
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Lookup returns the definition senses and synonyms for word. It first tries the
|
||||
// word as written (lowercased), then a few simple morphological reductions
|
||||
// (plurals, -ed/-ing/-ly) so "running" or "happily" still resolve. A word found
|
||||
// in neither dataset yields a Result with empty lists (not an error).
|
||||
func (l *Lexicon) Lookup(word string) (Result, error) {
|
||||
l.load()
|
||||
if l.loadErr != nil {
|
||||
return Result{}, l.loadErr
|
||||
}
|
||||
|
||||
norm := strings.ToLower(strings.TrimSpace(word))
|
||||
res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}}
|
||||
if norm == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res.Gloss = lookupGloss(l.gloss, norm)
|
||||
|
||||
if raw := lookupDefs(l.defs, norm); raw != nil {
|
||||
for _, m := range raw {
|
||||
res.Definitions = append(res.Definitions, toMeaning(m))
|
||||
if len(res.Definitions) >= maxDefinitions {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if syns := lookupSyns(l.synonyms, norm); syns != nil {
|
||||
if len(syns) > maxSynonyms {
|
||||
syns = syns[:maxSynonyms]
|
||||
}
|
||||
res.Synonyms = append(res.Synonyms, syns...)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Gloss returns just the Chinese translation for word (empty when absent). This
|
||||
// is the fast path behind the inline hover/select gloss — it skips the
|
||||
// definition and synonym datasets entirely.
|
||||
func (l *Lexicon) Gloss(word string) (GlossResult, error) {
|
||||
l.load()
|
||||
if l.loadErr != nil {
|
||||
return GlossResult{}, l.loadErr
|
||||
}
|
||||
norm := strings.ToLower(strings.TrimSpace(word))
|
||||
return GlossResult{Word: word, Gloss: lookupGloss(l.gloss, norm)}, nil
|
||||
}
|
||||
|
||||
// lookupGloss walks the candidate forms of a word and returns the first gloss
|
||||
// hit (so "running"/"studies" resolve via the same de-inflection as defs/syns).
|
||||
func lookupGloss(m map[string]string, word string) string {
|
||||
if word == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range candidates(word) {
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
|
||||
// first dataset hit. They're separate (rather than a generic helper) only
|
||||
// because the two maps have different value types.
|
||||
func lookupDefs(m map[string][][]string, word string) [][]string {
|
||||
for _, c := range candidates(word) {
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupSyns(m map[string][]string, word string) []string {
|
||||
for _, c := range candidates(word) {
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// candidates returns the lemma forms to try, in priority order: the word itself,
|
||||
// then conservative de-inflections. This is deliberately lightweight — a full
|
||||
// stemmer would over-reduce ("business" → "busy") and surface wrong entries; a
|
||||
// handful of common English suffix rules covers the everyday cases without a
|
||||
// dependency. Duplicates are fine (map lookup is cheap); order is what matters.
|
||||
func candidates(word string) []string {
|
||||
out := []string{word}
|
||||
add := func(s string) {
|
||||
if len(s) >= 2 && s != word {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(word, "ies"): // studies → study
|
||||
add(word[:len(word)-3] + "y")
|
||||
case strings.HasSuffix(word, "es"): // boxes → box, wishes → wish
|
||||
add(word[:len(word)-2])
|
||||
add(word[:len(word)-1])
|
||||
case strings.HasSuffix(word, "s"): // cats → cat
|
||||
add(word[:len(word)-1])
|
||||
}
|
||||
|
||||
if strings.HasSuffix(word, "ing") { // running → run, making → make
|
||||
stem := word[:len(word)-3]
|
||||
add(stem)
|
||||
add(stem + "e")
|
||||
add(undouble(stem))
|
||||
}
|
||||
if strings.HasSuffix(word, "ed") { // hoped → hope, stopped → stop
|
||||
stem := word[:len(word)-2]
|
||||
add(stem)
|
||||
add(word[:len(word)-1])
|
||||
add(undouble(stem))
|
||||
}
|
||||
if strings.HasSuffix(word, "ly") { // happily handled above via ies path? no — quickly → quick
|
||||
add(word[:len(word)-2])
|
||||
}
|
||||
if strings.HasSuffix(word, "ily") { // happily → happy
|
||||
add(word[:len(word)-3] + "y")
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// undouble collapses a doubled final consonant (stopp → stop, runn → run) so the
|
||||
// -ed/-ing stems of doubled-consonant verbs resolve to their base form.
|
||||
func undouble(stem string) string {
|
||||
n := len(stem)
|
||||
if n >= 2 && stem[n-1] == stem[n-2] {
|
||||
return stem[:n-1]
|
||||
}
|
||||
return stem
|
||||
}
|
||||
|
||||
// toMeaning maps a compact [pos, def, example] triple from the dataset onto the
|
||||
// JSON-friendly Meaning. The dataset always stores three elements, but we guard
|
||||
// the length so a malformed row can't panic.
|
||||
func toMeaning(m []string) Meaning {
|
||||
var out Meaning
|
||||
if len(m) > 0 {
|
||||
out.PartOfSpeech = m[0]
|
||||
}
|
||||
if len(m) > 1 {
|
||||
out.Definition = m[1]
|
||||
}
|
||||
if len(m) > 2 {
|
||||
out.Example = m[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gunzipJSON decompresses gz and decodes the JSON into v.
|
||||
func gunzipJSON(gz []byte, v any) error {
|
||||
r, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
118
internal/lexicon/lexicon_test.go
Normal file
118
internal/lexicon/lexicon_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package lexicon
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLookupKnownWord(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("happy")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if len(res.Definitions) == 0 {
|
||||
t.Errorf("expected definitions for %q, got none", "happy")
|
||||
}
|
||||
if len(res.Synonyms) == 0 {
|
||||
t.Errorf("expected synonyms for %q, got none", "happy")
|
||||
}
|
||||
if len(res.Synonyms) > maxSynonyms {
|
||||
t.Errorf("synonyms not capped: got %d, want <= %d", len(res.Synonyms), maxSynonyms)
|
||||
}
|
||||
if len(res.Definitions) > maxDefinitions {
|
||||
t.Errorf("definitions not capped: got %d, want <= %d", len(res.Definitions), maxDefinitions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupMorphology(t *testing.T) {
|
||||
l := New()
|
||||
// Inflected forms should resolve to their base entry via candidates().
|
||||
for _, w := range []string{"running", "studies", "boxes", "quickly", "stopped"} {
|
||||
res, err := l.Lookup(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(%q): %v", w, err)
|
||||
}
|
||||
if len(res.Definitions) == 0 && len(res.Synonyms) == 0 {
|
||||
t.Errorf("expected some result for inflected %q, got nothing", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupUnknownWord(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("zzzxqqq")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if len(res.Definitions) != 0 || len(res.Synonyms) != 0 {
|
||||
t.Errorf("expected empty result for nonsense word, got %+v", res)
|
||||
}
|
||||
// Empty result must still serialize as [] not null for the frontend.
|
||||
if res.Definitions == nil || res.Synonyms == nil {
|
||||
t.Errorf("empty slices must be non-nil for JSON []: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGloss(t *testing.T) {
|
||||
l := New()
|
||||
// A common word carries a Chinese gloss...
|
||||
res, err := l.Gloss("river")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss: %v", err)
|
||||
}
|
||||
if res.Word != "river" {
|
||||
t.Errorf("Word = %q, want %q", res.Word, "river")
|
||||
}
|
||||
if res.Gloss == "" {
|
||||
t.Errorf("expected a Chinese gloss for %q, got none", "river")
|
||||
}
|
||||
// ...and the inflected form resolves via the same de-inflection.
|
||||
inflected, err := l.Gloss("rivers")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss(rivers): %v", err)
|
||||
}
|
||||
if inflected.Gloss == "" {
|
||||
t.Errorf("expected a gloss for inflected %q, got none", "rivers")
|
||||
}
|
||||
// A nonsense word is a clean empty (not an error).
|
||||
miss, err := l.Gloss("zzzxqqq")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss(miss): %v", err)
|
||||
}
|
||||
if miss.Gloss != "" {
|
||||
t.Errorf("expected empty gloss for nonsense word, got %q", miss.Gloss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIncludesGloss(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("happy")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if res.Gloss == "" {
|
||||
t.Errorf("expected Lookup to include a Chinese gloss for %q", "happy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidates(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"cats": "cat",
|
||||
"studies": "study",
|
||||
"running": "run",
|
||||
"hoped": "hope",
|
||||
"quickly": "quick",
|
||||
"happily": "happy",
|
||||
}
|
||||
for word, want := range cases {
|
||||
got := candidates(word)
|
||||
found := false
|
||||
for _, c := range got {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("candidates(%q) = %v, missing expected base %q", word, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,9 @@ type checkpointResponse struct {
|
||||
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
||||
// applies the latency-guard truncation and the checkpoint sampling parameters
|
||||
// from the spec.
|
||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: CheckpointMessages(TruncateDoc(contentText)),
|
||||
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
||||
MaxTokens: 1024,
|
||||
Temperature: 0.3,
|
||||
RepetitionPenalty: 1.15,
|
||||
|
||||
@@ -8,7 +8,7 @@ const checkpointSystemPrompt = `You are a warm, encouraging writing assistant he
|
||||
`Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
|
||||
`incorrect idiom usage, or unclear sentences that are common ESL patterns.
|
||||
|
||||
Be specific, friendly, and explain WHY each suggestion improves the writing.
|
||||
Be specific, friendly, and explain WHY each suggestion improves the writing.%s
|
||||
|
||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
{
|
||||
@@ -24,11 +24,32 @@ Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
|
||||
If the writing looks good, return: {"suggestions": []}`
|
||||
|
||||
// toneGuidance returns a sentence steering the checkpoint toward the writer's
|
||||
// chosen tone, or "" for the neutral default. The clause is appended to the
|
||||
// checkpoint instructions so the model's phrasing suggestions fit the target
|
||||
// register (e.g. an academic essay vs a casual journal). Unknown values fall
|
||||
// back to no steering, so a stray tone string is harmless.
|
||||
func toneGuidance(tone string) string {
|
||||
clause, ok := map[string]string{
|
||||
"academic": "formal, academic, and objective — suited to a school essay or research paper",
|
||||
"professional": "polished and professional — suited to a workplace email or report",
|
||||
"casual": "relaxed, friendly, and conversational",
|
||||
"humorous": "light, playful, and good-humored",
|
||||
"creative": "vivid, expressive, and imaginative — suited to a story or personal narrative",
|
||||
"persuasive": "confident and persuasive — suited to an argument or opinion piece",
|
||||
}[tone]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return "\n\nThe writer wants this document to read as " + clause + ". When phrasing could " +
|
||||
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
|
||||
}
|
||||
|
||||
// CheckpointMessages builds the message array for a grammar checkpoint over the
|
||||
// given (already-truncated) document text.
|
||||
func CheckpointMessages(contentText string) []Message {
|
||||
// given (already-truncated) document text, steered toward the document's tone.
|
||||
func CheckpointMessages(contentText, tone string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: checkpointSystemPrompt},
|
||||
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
|
||||
{Role: "user", Content: contentText},
|
||||
}
|
||||
}
|
||||
@@ -99,3 +120,48 @@ Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encoura
|
||||
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
|
||||
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
|
||||
}
|
||||
|
||||
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
|
||||
// The writer selects a passage and picks a style; the model rewrites that
|
||||
// passage in place. The instruction is deliberately strict about returning ONLY
|
||||
// the rewritten passage so the result can be dropped straight into the editor —
|
||||
// no quotes, no preamble, no commentary to strip.
|
||||
const rewriteSystemTemplate = `You are Petal, a warm English writing assistant helping someone who speaks English ` +
|
||||
`as a second language. Rewrite the passage the user sends so that it %s, while preserving its original ` +
|
||||
`meaning. Fix any grammar mistakes and awkward phrasing along the way. Keep it about the same length — ` +
|
||||
`do not add new ideas, explanations, or commentary.
|
||||
|
||||
Respond with ONLY the rewritten passage. No quotation marks around it, no preamble, no notes — just the ` +
|
||||
`rewritten English text, ready to drop back into the document.`
|
||||
|
||||
// styleGuidance maps a rewrite style onto the clause describing the target
|
||||
// register. "natural" is the default "say it more naturally" action; the rest
|
||||
// mirror the document-tone vocabulary (see toneGuidance / the ToneSelect UI).
|
||||
// An unknown style falls back to the natural rewrite.
|
||||
func styleGuidance(style string) string {
|
||||
switch style {
|
||||
case "academic":
|
||||
return "reads as formal, academic English suited to a school essay or research paper"
|
||||
case "professional":
|
||||
return "reads as polished, professional English suited to a workplace email or report"
|
||||
case "casual":
|
||||
return "sounds relaxed, friendly, and conversational"
|
||||
case "humorous":
|
||||
return "has a light, playful, good-humored tone"
|
||||
case "creative":
|
||||
return "is vivid, expressive, and imaginative"
|
||||
case "persuasive":
|
||||
return "is confident and persuasive"
|
||||
default: // "natural"
|
||||
return "sounds natural and fluent, the way a native English speaker would naturally say it"
|
||||
}
|
||||
}
|
||||
|
||||
// RewriteMessages builds the message array for a tone-rewrite: the styled system
|
||||
// instruction plus the passage to rewrite as the user turn.
|
||||
func RewriteMessages(text, style string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: fmt.Sprintf(rewriteSystemTemplate, styleGuidance(style))},
|
||||
{Role: "user", Content: text},
|
||||
}
|
||||
}
|
||||
|
||||
46
internal/llm/rewrite.go
Normal file
46
internal/llm/rewrite.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RewriteMaxRunes caps how much selected text a single rewrite will accept. A
|
||||
// rewrite is a focused "fix this sentence/paragraph" action, not a whole-doc
|
||||
// pass — bounding it keeps latency sane and the model on-task. The handler
|
||||
// rejects longer selections before calling the model.
|
||||
const RewriteMaxRunes = 2000
|
||||
|
||||
// RunRewrite rewrites a selected passage in the requested style (e.g. "natural",
|
||||
// "academic"). It is a one-shot Complete — the result is shown as a preview the
|
||||
// writer accepts or discards, so we want the whole rewrite before rendering.
|
||||
func RunRewrite(ctx context.Context, client LLMClient, text, style string) (string, error) {
|
||||
out, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: RewriteMessages(text, style),
|
||||
MaxTokens: 1024,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
RepetitionPenalty: 1.1,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanRewrite(out), nil
|
||||
}
|
||||
|
||||
// cleanRewrite trims the model's output down to just the rewritten passage. The
|
||||
// prompt asks for no quotes or preamble, but small instruct models occasionally
|
||||
// wrap the answer in matching quotes — strip a single surrounding pair so the
|
||||
// text drops cleanly into the editor.
|
||||
func cleanRewrite(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 {
|
||||
first, last := s[0], s[len(s)-1]
|
||||
if (first == '"' && last == '"') ||
|
||||
(first == '\'' && last == '\'') ||
|
||||
(strings.HasPrefix(s, "“") && strings.HasSuffix(s, "”")) {
|
||||
s = strings.TrimSpace(strings.Trim(s, "\"'“”"))
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -16,7 +16,10 @@ const VoiceInterval = 20 * time.Second
|
||||
// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's
|
||||
// tolerant parser and a larger token budget, since one pass may flag several
|
||||
// passages. Each flag carries a null replacement (awareness-only).
|
||||
func RunVoice(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
||||
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
||||
// consistency is judged against the document's own established voice, not an
|
||||
// externally-chosen register.
|
||||
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: VoiceMessages(contentText),
|
||||
MaxTokens: 2048,
|
||||
|
||||
@@ -44,6 +44,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||
r.Post("/{id}/check", h.check)
|
||||
r.Post("/{id}/voice", h.voice)
|
||||
r.Post("/{id}/rewrite", h.rewrite)
|
||||
r.Get("/{id}/suggestions", h.listForDoc)
|
||||
}
|
||||
|
||||
@@ -69,8 +70,9 @@ func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||
// given the document text it returns the model's raw suggestions.
|
||||
type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([]llm.RawSuggestion, error)
|
||||
// given the document text and the document's tone it returns the model's raw
|
||||
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
||||
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string) ([]llm.RawSuggestion, error)
|
||||
|
||||
// runPass is the shared body for both LLM passes. It loads the document text,
|
||||
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
||||
@@ -79,11 +81,11 @@ type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([
|
||||
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
var contentText string
|
||||
var contentText, tone string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
|
||||
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&contentText)
|
||||
).Scan(&contentText, &tone)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
@@ -111,7 +113,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := run(r.Context(), h.Client, contentText)
|
||||
raw, err := run(r.Context(), h.Client, contentText, tone)
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||
return
|
||||
|
||||
76
internal/suggestions/rewrite.go
Normal file
76
internal/suggestions/rewrite.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
// rewriteRequest is the body the selection bubble posts: the selected passage
|
||||
// and the target style ("natural", "academic", …). The text is the client's
|
||||
// live selection — unlike a checkpoint, there is nothing to anchor server-side,
|
||||
// so the rewrite is stateless and never persisted (the editor applies it
|
||||
// directly, and the version history captures the resulting document change).
|
||||
type rewriteRequest struct {
|
||||
Text string `json:"text"`
|
||||
Style string `json:"style"`
|
||||
}
|
||||
|
||||
type rewriteResponse struct {
|
||||
Rewrite string `json:"rewrite"`
|
||||
}
|
||||
|
||||
// rewrite runs a one-shot tone-rewrite over a selected passage and returns the
|
||||
// rewritten text for the editor to preview. The document id scopes the request
|
||||
// to the owner (and reserves room to feed document context to the model later),
|
||||
// even though the passage itself rides in the body.
|
||||
func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
var body rewriteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(body.Text)
|
||||
if text == "" {
|
||||
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
||||
return
|
||||
}
|
||||
if len([]rune(text)) > llm.RewriteMaxRunes {
|
||||
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
|
||||
return
|
||||
}
|
||||
|
||||
// Scope to the owner so a stray id can't drive rewrites against another
|
||||
// user's document (and 404 cleanly when it doesn't exist).
|
||||
var exists int
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
|
||||
}
|
||||
79
internal/suggestions/rewrite_test.go
Normal file
79
internal/suggestions/rewrite_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
// recordingClient captures the last Complete request so the rewrite test can
|
||||
// assert the styled system prompt and the selected passage reached the model.
|
||||
type recordingClient struct {
|
||||
response string
|
||||
last llm.CompletionRequest
|
||||
}
|
||||
|
||||
func (c *recordingClient) Complete(_ context.Context, req llm.CompletionRequest) (string, error) {
|
||||
c.last = req
|
||||
return c.response, nil
|
||||
}
|
||||
|
||||
func (c *recordingClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
|
||||
ch := make(chan string)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRewrite(t *testing.T) {
|
||||
// The model wraps its answer in quotes; cleanRewrite should strip them.
|
||||
client := &recordingClient{response: `"I have two apples."`}
|
||||
srv, docID, _ := newTestServer(t, client)
|
||||
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite",
|
||||
`{"text":"I has two apple.","style":"academic"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("rewrite: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var resp rewriteResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Rewrite != "I have two apples." {
|
||||
t.Fatalf("rewrite = %q, want the de-quoted text", resp.Rewrite)
|
||||
}
|
||||
|
||||
// The passage rode in as the user turn, and the style steered the system turn.
|
||||
msgs := client.last.Messages
|
||||
if len(msgs) != 2 || msgs[0].Role != "system" || msgs[1].Role != "user" {
|
||||
t.Fatalf("unexpected message shape: %+v", msgs)
|
||||
}
|
||||
if msgs[1].Content != "I has two apple." {
|
||||
t.Fatalf("passage not forwarded: %q", msgs[1].Content)
|
||||
}
|
||||
if !strings.Contains(msgs[0].Content, "academic") {
|
||||
t.Fatalf("system prompt missing academic style guidance:\n%s", msgs[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteEmptyText(t *testing.T) {
|
||||
client := &recordingClient{response: "anything"}
|
||||
srv, docID, _ := newTestServer(t, client)
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite", `{"text":" ","style":"natural"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty text: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteUnknownDoc(t *testing.T) {
|
||||
client := &recordingClient{response: "anything"}
|
||||
srv, _, _ := newTestServer(t, client)
|
||||
rec := do(t, srv, http.MethodPost, "/docs/does-not-exist/rewrite",
|
||||
`{"text":"hello there","style":"natural"}`)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown doc: want 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
106
scripts/build_gloss.py
Normal file
106
scripts/build_gloss.py
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the embedded English->Chinese gloss dataset from ECDICT.
|
||||
|
||||
Petal shows an instant Chinese gloss when the writer (an English-as-a-second-
|
||||
language user whose first language is Mandarin) hovers or right-clicks a word.
|
||||
The gloss is served offline from a small map compiled into the Go binary, the
|
||||
same way the English definitions/synonyms are (see internal/lexicon).
|
||||
|
||||
Source: ECDICT (https://github.com/skywind3000/ECDICT), MIT-licensed. We keep
|
||||
only common single words that carry a Chinese translation, trim each gloss to a
|
||||
couple of senses, and drop the noisy "[网络]" (internet-slang) lines — the result
|
||||
gzips to a few MB, in line with the other lexicon assets.
|
||||
|
||||
Usage:
|
||||
curl -sL https://raw.githubusercontent.com/skywind3000/ECDICT/master/ecdict.csv -o ecdict.csv
|
||||
python3 scripts/build_gloss.py ecdict.csv internal/lexicon/data/gloss.json.gz
|
||||
"""
|
||||
import csv
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Frequency gate: keep a word only if ECDICT ranks it in the BNC or COCA
|
||||
# frequency lists (frq/bnc > 0). That bounds the asset to the words an ESL
|
||||
# writer actually meets, dropping the long tail of archaic/technical headwords.
|
||||
# Words below this rank but present nowhere in the frequency lists are skipped.
|
||||
MAX_RANK = 50000
|
||||
|
||||
# A gloss is at most this many sense-lines and characters, so the hover bubble
|
||||
# stays a glance, not a wall of text.
|
||||
MAX_SENSES = 3
|
||||
MAX_CHARS = 80
|
||||
|
||||
# Single English word: letters, with internal apostrophe/hyphen (so "don't",
|
||||
# "well-being" survive but multi-word phrases and codes are dropped).
|
||||
WORD_RE = re.compile(r"^[a-z][a-z'\-]*[a-z]$|^[a-z]$")
|
||||
|
||||
# Tag lines we drop from a translation: "[网络]" is crowd-sourced internet slang,
|
||||
# "[俚]" slang, "[古]" archaic — none help an ESL writer pick everyday meaning.
|
||||
DROP_TAG_RE = re.compile(r"^\[(网络|俚|古|罕|废)\]")
|
||||
|
||||
|
||||
def clean_gloss(translation: str) -> str:
|
||||
"""Trim an ECDICT translation to a compact, glanceable Chinese gloss."""
|
||||
# ECDICT separates senses with a literal backslash-n; normalize to real
|
||||
# newlines (and tolerate genuine newlines) before splitting.
|
||||
translation = translation.replace("\\n", "\n")
|
||||
senses = []
|
||||
for line in translation.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or DROP_TAG_RE.match(line):
|
||||
continue
|
||||
senses.append(line)
|
||||
if len(senses) >= MAX_SENSES:
|
||||
break
|
||||
gloss = ";".join(senses)
|
||||
if len(gloss) > MAX_CHARS:
|
||||
gloss = gloss[:MAX_CHARS].rstrip(";,;, ") + "…"
|
||||
return gloss
|
||||
|
||||
|
||||
def rank(row: dict) -> int:
|
||||
"""Best (lowest, non-zero) frequency rank across COCA and BNC."""
|
||||
ranks = []
|
||||
for key in ("frq", "bnc"):
|
||||
try:
|
||||
v = int(row.get(key) or 0)
|
||||
except ValueError:
|
||||
v = 0
|
||||
if v > 0:
|
||||
ranks.append(v)
|
||||
return min(ranks) if ranks else 0
|
||||
|
||||
|
||||
def main(src: str, dst: str) -> None:
|
||||
out: dict[str, str] = {}
|
||||
kept_rank: dict[str, int] = {}
|
||||
with open(src, newline="", encoding="utf-8") as f:
|
||||
for row in csv.DictReader(f):
|
||||
word = (row.get("word") or "").strip().lower()
|
||||
translation = (row.get("translation") or "").strip()
|
||||
if not word or not translation or not WORD_RE.match(word):
|
||||
continue
|
||||
r = rank(row)
|
||||
if r == 0 or r > MAX_RANK:
|
||||
continue
|
||||
gloss = clean_gloss(translation)
|
||||
if not gloss:
|
||||
continue
|
||||
# On a duplicate headword keep the more frequent (lower-rank) entry.
|
||||
if word in kept_rank and kept_rank[word] <= r:
|
||||
continue
|
||||
out[word] = gloss
|
||||
kept_rank[word] = r
|
||||
|
||||
payload = json.dumps(out, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
with gzip.open(dst, "wb", compresslevel=9) as gz:
|
||||
gz.write(payload)
|
||||
print(f"{len(out)} words -> {dst} ({len(payload)} bytes json)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit("usage: build_gloss.py <ecdict.csv> <out.json.gz>")
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
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))
|
||||
364
web/package-lock.json
generated
364
web/package-lock.json
generated
@@ -28,7 +28,8 @@
|
||||
"dictionary-en": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1178,6 +1179,13 @@
|
||||
"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": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
||||
@@ -1934,6 +1942,24 @@
|
||||
"@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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -2010,12 +2036,135 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"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": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
@@ -2084,6 +2233,16 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2176,6 +2335,13 @@
|
||||
"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": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -2240,6 +2406,26 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2746,12 +2932,33 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3103,6 +3310,13 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3113,6 +3327,20 @@
|
||||
"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": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
||||
@@ -3134,6 +3362,23 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -3151,6 +3396,16 @@
|
||||
"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": {
|
||||
"version": "6.3.7",
|
||||
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||
@@ -3295,12 +3550,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": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
@@ -29,6 +31,7 @@
|
||||
"dictionary-en": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
267
web/src/App.tsx
267
web/src/App.tsx
@@ -1,14 +1,20 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
|
||||
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||
import { useTags } from './hooks/useTags'
|
||||
import { DocList } from './components/DocList/DocList'
|
||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||
import { ExportMenu } from './components/Export/ExportMenu'
|
||||
import { HistoryPanel } from './components/History/HistoryPanel'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||
import { PetalFall } from './effects/PetalFall'
|
||||
import { playSuggestionSound } from './audio/sounds'
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionWatch()
|
||||
@@ -16,41 +22,128 @@ export default function App() {
|
||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [wordCount, setWordCount] = useState(0)
|
||||
// The current document's target tone (steers checkpoint advice) and its live
|
||||
// plain text (drives the expanded writing-stats panel in the StatusBar).
|
||||
const [tone, setTone] = useState('general')
|
||||
const [docText, setDocText] = useState('')
|
||||
const [ready, setReady] = useState(false)
|
||||
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
||||
// sidebar. Escape or a click outside the editor canvas restores it.
|
||||
const [focusMode, setFocusMode] = useState(false)
|
||||
// Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled
|
||||
// by the header hamburger. Ignored on wide screens (sidebar is always in-flow).
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
// Monotonic counters the companion watches to react to writing + accepts.
|
||||
const [editTick, setEditTick] = useState(0)
|
||||
const [acceptTick, setAcceptTick] = useState(0)
|
||||
// History drawer visibility, and an epoch bumped on restore to force the
|
||||
// editor to remount with the restored content (its initialContent is read
|
||||
// only on mount).
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [editorEpoch, setEditorEpoch] = useState(0)
|
||||
|
||||
// Live mirrors of the current doc's editable fields so the "discard blank
|
||||
// drafts on navigation" logic can read the latest values without rebuilding
|
||||
// callbacks on every keystroke.
|
||||
const currentDocRef = useRef(currentDoc)
|
||||
const titleRef = useRef(title)
|
||||
const wordCountRef = useRef(wordCount)
|
||||
currentDocRef.current = currentDoc
|
||||
titleRef.current = title
|
||||
wordCountRef.current = wordCount
|
||||
|
||||
// A throwaway blank draft: no words and still the default/empty title. We
|
||||
// delete these on navigation rather than leave orphan "Untitled" docs behind.
|
||||
const isBlankDraft = useCallback(() => {
|
||||
const t = titleRef.current.trim()
|
||||
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
||||
}, [])
|
||||
|
||||
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||
const {
|
||||
suggestions,
|
||||
checking,
|
||||
voicing,
|
||||
llmDown,
|
||||
schedule: scheduleCheckpoint,
|
||||
runVoice,
|
||||
removeSuggestion,
|
||||
} = useCheckpoint(currentDoc?.id ?? null)
|
||||
// Browser-side spell checker — loads the en-US dictionary once per session.
|
||||
const { checker: spellChecker, addWord } = useSpellChecker()
|
||||
// The tag roster (with counts). Assignments live on the doc summaries below.
|
||||
const { tags: tagRoster, refresh: refreshTags, createTag } = useTags()
|
||||
|
||||
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
||||
}, [])
|
||||
|
||||
// Attach or detach a tag on a document, updating the sidebar optimistically and
|
||||
// refreshing the roster so its counts stay current. Tags are kept sorted by
|
||||
// name to match the server's ordering.
|
||||
const setDocTag = useCallback(
|
||||
async (docId: string, tag: Tag, attach: boolean) => {
|
||||
setDocs((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== docId) return d
|
||||
const without = d.tags.filter((t) => t.id !== tag.id)
|
||||
const next = attach ? [...without, tag] : without
|
||||
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||
return { ...d, tags: next }
|
||||
}),
|
||||
)
|
||||
try {
|
||||
if (attach) await api.assignTag(docId, tag.id)
|
||||
else await api.unassignTag(docId, tag.id)
|
||||
void refreshTags()
|
||||
} catch (err) {
|
||||
console.error('tag assignment failed', err)
|
||||
void refreshTags()
|
||||
}
|
||||
},
|
||||
[refreshTags],
|
||||
)
|
||||
|
||||
const handleToggleTag = useCallback(
|
||||
(docId: string, tag: Tag) => {
|
||||
const doc = docs.find((d) => d.id === docId)
|
||||
const has = doc?.tags.some((t) => t.id === tag.id) ?? false
|
||||
void setDocTag(docId, tag, !has)
|
||||
},
|
||||
[docs, setDocTag],
|
||||
)
|
||||
|
||||
const handleCreateTag = useCallback(
|
||||
async (docId: string, name: string, color: TagColor) => {
|
||||
const tag = await createTag(name, color)
|
||||
if (tag) void setDocTag(docId, tag, true)
|
||||
},
|
||||
[createTag, setDocTag],
|
||||
)
|
||||
|
||||
const openDoc = useCallback(
|
||||
async (id: string) => {
|
||||
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
||||
// Decide the leaving doc's fate before any await mutates state.
|
||||
const leaving = currentDocRef.current
|
||||
const leavingBlank = isBlankDraft()
|
||||
await saveNow() // flush any pending edits to the doc we're leaving
|
||||
const doc = await api.getDoc(id)
|
||||
setCurrentDoc(doc)
|
||||
setTitle(doc.title)
|
||||
setWordCount(doc.word_count)
|
||||
setTone(doc.tone || 'general')
|
||||
setDocText(doc.content_text)
|
||||
// Leaving an untouched blank draft for a different doc? Drop it silently
|
||||
// (best-effort cleanup — a 404 just means it's already gone).
|
||||
if (leaving && leaving.id !== id && leavingBlank) {
|
||||
api.deleteDoc(leaving.id).catch(() => {})
|
||||
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
|
||||
}
|
||||
},
|
||||
[saveNow],
|
||||
[saveNow, isBlankDraft],
|
||||
)
|
||||
|
||||
// Initial load: fetch the list, opening the first doc (or creating one).
|
||||
@@ -63,11 +156,13 @@ export default function App() {
|
||||
let list = await api.listDocs()
|
||||
if (list.length === 0) {
|
||||
const fresh = await api.createDoc()
|
||||
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }]
|
||||
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }]
|
||||
setDocs(list)
|
||||
setCurrentDoc(fresh)
|
||||
setTitle(fresh.title)
|
||||
setWordCount(0)
|
||||
setTone(fresh.tone || 'general')
|
||||
setDocText('')
|
||||
} else {
|
||||
setDocs(list)
|
||||
await openDoc(list[0].id)
|
||||
@@ -81,16 +176,21 @@ export default function App() {
|
||||
}, [openDoc])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
// Already sitting on a fresh blank draft? Reuse it instead of stacking
|
||||
// another empty "Untitled" on top.
|
||||
if (currentDocRef.current && isBlankDraft()) return
|
||||
await saveNow()
|
||||
const fresh = await api.createDoc()
|
||||
setDocs((prev) => [
|
||||
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at },
|
||||
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
|
||||
...prev,
|
||||
])
|
||||
setCurrentDoc(fresh)
|
||||
setTitle(fresh.title)
|
||||
setWordCount(0)
|
||||
}, [saveNow])
|
||||
setTone(fresh.tone || 'general')
|
||||
setDocText('')
|
||||
}, [saveNow, isBlankDraft])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -104,6 +204,8 @@ export default function App() {
|
||||
setCurrentDoc(null)
|
||||
setTitle('')
|
||||
setWordCount(0)
|
||||
setTone('general')
|
||||
setDocText('')
|
||||
}
|
||||
}
|
||||
return remaining
|
||||
@@ -126,6 +228,7 @@ export default function App() {
|
||||
const handleEditorChange = useCallback(
|
||||
(change: EditorChange) => {
|
||||
setWordCount(change.word_count)
|
||||
setDocText(change.content_text)
|
||||
setEditTick((n) => n + 1)
|
||||
if (currentDoc) {
|
||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||
@@ -136,6 +239,20 @@ export default function App() {
|
||||
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
||||
)
|
||||
|
||||
// Changing the tone persists it and re-runs the checkpoint so Petal's advice
|
||||
// re-tunes to the new register. The auto-save (1.5s) lands before the
|
||||
// checkpoint debounce (4s), so the server reads the updated tone.
|
||||
const handleToneChange = useCallback(
|
||||
(value: string) => {
|
||||
setTone(value)
|
||||
if (currentDoc) {
|
||||
schedule({ tone: value })
|
||||
scheduleCheckpoint()
|
||||
}
|
||||
},
|
||||
[currentDoc, schedule, scheduleCheckpoint],
|
||||
)
|
||||
|
||||
// Accept applies the replacement in the editor (handled in EditorCore) and
|
||||
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
|
||||
const handleAccept = useCallback(
|
||||
@@ -151,6 +268,21 @@ export default function App() {
|
||||
[removeSuggestion],
|
||||
)
|
||||
|
||||
// After restoring a version, swap the restored doc into the editor. Bumping
|
||||
// editorEpoch remounts EditorCore so it picks up the restored content.
|
||||
const handleRestored = useCallback(
|
||||
(doc: Document) => {
|
||||
setCurrentDoc(doc)
|
||||
setTitle(doc.title)
|
||||
setWordCount(doc.word_count)
|
||||
setTone(doc.tone || 'general')
|
||||
setDocText(doc.content_text)
|
||||
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
|
||||
setEditorEpoch((n) => n + 1)
|
||||
},
|
||||
[patchSummary],
|
||||
)
|
||||
|
||||
// Escape always restores the sidebar while in distraction-free mode.
|
||||
useEffect(() => {
|
||||
if (!focusMode) return
|
||||
@@ -179,28 +311,77 @@ export default function App() {
|
||||
[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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<PetalFall />
|
||||
<header
|
||||
onMouseDown={handleChromeDown}
|
||||
className="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"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle document list"
|
||||
onClick={() => {
|
||||
setFocusMode(false)
|
||||
setDrawerOpen((v) => !v)
|
||||
}}
|
||||
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
|
||||
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<span className="text-xl">🌸</span>
|
||||
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
|
||||
>
|
||||
<DocList
|
||||
docs={docs}
|
||||
roster={tagRoster}
|
||||
selectedId={currentDoc?.id ?? null}
|
||||
onSelect={openDoc}
|
||||
onCreate={handleCreate}
|
||||
onDelete={handleDelete}
|
||||
onToggleTag={handleToggleTag}
|
||||
onCreateTag={handleCreateTag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
{currentDoc ? (
|
||||
<>
|
||||
@@ -209,16 +390,41 @@ export default function App() {
|
||||
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
||||
>
|
||||
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Untitled"
|
||||
aria-label="Document title"
|
||||
className="mb-5 w-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||
style={{ fontFamily: 'var(--font-ui)' }}
|
||||
/>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Untitled"
|
||||
aria-label="Document title"
|
||||
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||
style={{ fontFamily: 'var(--font-ui)' }}
|
||||
/>
|
||||
<div className="petal-no-print shrink-0">
|
||||
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
aria-label="Version history"
|
||||
title="Browse and restore earlier versions"
|
||||
className="petal-no-print inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>🕘</span>
|
||||
<span>历史</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
||||
</button>
|
||||
<div className="petal-no-print">
|
||||
<ExportMenu docId={currentDoc.id} />
|
||||
</div>
|
||||
</div>
|
||||
<EditorCore
|
||||
key={currentDoc.id}
|
||||
key={`${currentDoc.id}:${editorEpoch}`}
|
||||
docId={currentDoc.id}
|
||||
initialContent={currentDoc.content}
|
||||
onChange={handleEditorChange}
|
||||
@@ -233,12 +439,14 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div onMouseDown={handleChromeDown}>
|
||||
<div className="petal-no-print" onMouseDown={handleChromeDown}>
|
||||
<StatusBar
|
||||
wordCount={wordCount}
|
||||
text={docText}
|
||||
saveStatus={status}
|
||||
checking={checking}
|
||||
voicing={voicing}
|
||||
llmDown={llmDown}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -253,14 +461,25 @@ export default function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{historyOpen && currentDoc && (
|
||||
<HistoryPanel
|
||||
docId={currentDoc.id}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRestored={handleRestored}
|
||||
/>
|
||||
)}
|
||||
|
||||
{updateAvailable && <UpdateBanner />}
|
||||
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
/>
|
||||
<div className="petal-no-print">
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
text={docText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
|
||||
// Vite proxies to the Go server in dev and the binary serves directly in prod.
|
||||
|
||||
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
|
||||
// backend TagColor* constants; unknown values render as rose.
|
||||
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color: TagColor
|
||||
doc_count?: number // present only in the tag-roster listing
|
||||
}
|
||||
|
||||
export interface DocSummary {
|
||||
id: string
|
||||
title: string
|
||||
word_count: number
|
||||
updated_at: string
|
||||
tags: Tag[]
|
||||
}
|
||||
|
||||
// One cross-document search hit. `snippet` is plain text with the matched span
|
||||
// wrapped in the … sentinels (see splitSnippet) for highlighting.
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
title: string
|
||||
word_count: number
|
||||
updated_at: string
|
||||
snippet: string
|
||||
tags: Tag[]
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
@@ -14,6 +37,7 @@ export interface Document {
|
||||
title: string
|
||||
content: string // Tiptap JSON (stringified)
|
||||
content_text: string // flattened plain text
|
||||
tone: string // target writing tone; steers LLM advice
|
||||
word_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -25,11 +49,51 @@ export interface DocUpdate {
|
||||
title?: string
|
||||
content?: string
|
||||
content_text?: string
|
||||
tone?: string
|
||||
word_count?: number
|
||||
}
|
||||
|
||||
// One sense of a word from the offline dictionary.
|
||||
export interface WordMeaning {
|
||||
part_of_speech: string
|
||||
definition: string
|
||||
example?: string
|
||||
}
|
||||
|
||||
// The offline lookup for one word: a Chinese gloss, a few definition senses, and
|
||||
// a list of synonyms. Any of these may be empty when the word isn't a headword.
|
||||
export interface WordInfo {
|
||||
word: string
|
||||
gloss: string // Chinese translation; '' when the word isn't in the gloss set
|
||||
definitions: WordMeaning[]
|
||||
synonyms: string[]
|
||||
}
|
||||
|
||||
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
|
||||
export interface Gloss {
|
||||
word: string
|
||||
gloss: string
|
||||
}
|
||||
|
||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||
|
||||
// A point-in-time snapshot of a document. List responses omit the heavy
|
||||
// content/content_text fields; they arrive only on getVersion (preview/restore).
|
||||
export interface DocumentVersion {
|
||||
id: string
|
||||
doc_id: string
|
||||
title: string
|
||||
content?: string
|
||||
content_text?: string
|
||||
word_count: number
|
||||
kind: 'auto' | 'manual' | 'pre_restore'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Downloadable export formats. PDF is handled client-side via the browser's
|
||||
// print dialog (CJK-safe, no server-side font embedding).
|
||||
export type ExportFormat = 'md' | 'html' | 'txt' | 'docx'
|
||||
|
||||
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
||||
// the editor re-anchors by matching this string in the live document (spec
|
||||
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
|
||||
@@ -83,11 +147,109 @@ export const api = {
|
||||
dismissSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||
|
||||
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||
// point; restoreVersion copies a snapshot back onto the live doc (capturing a
|
||||
// pre_restore safety copy server-side first) and returns the restored doc.
|
||||
listVersions: (id: string) => req<DocumentVersion[]>(`/docs/${id}/versions`),
|
||||
getVersion: (id: string, vid: string) =>
|
||||
req<DocumentVersion>(`/docs/${id}/versions/${vid}`),
|
||||
snapshotDoc: (id: string) =>
|
||||
req<DocumentVersion>(`/docs/${id}/versions`, { method: 'POST' }),
|
||||
restoreVersion: (id: string, vid: string) =>
|
||||
req<Document>(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }),
|
||||
|
||||
// Download URL for an exported document (md/html/txt/docx). Used as an <a
|
||||
// href download> target so the browser handles the file save.
|
||||
exportUrl: (id: string, format: ExportFormat) =>
|
||||
`/api/docs/${id}/export?format=${format}`,
|
||||
|
||||
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
|
||||
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
||||
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
|
||||
// and offline, so it fires on hover without spinning up the heavier lookup.
|
||||
glossWord: (word: string) => req<Gloss>(`/gloss/${encodeURIComponent(word)}`),
|
||||
// Tone-rewrite: rewrites a selected passage in the given style ('natural',
|
||||
// 'academic', …) and returns the rewritten text for an in-editor preview. Not
|
||||
// persisted — the editor applies it directly on accept.
|
||||
rewriteSelection: (docId: string, text: string, style: string) =>
|
||||
req<{ rewrite: string }>(`/docs/${docId}/rewrite`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, style }),
|
||||
}),
|
||||
|
||||
// Cross-document full-text search (title + body). Returns hits with a
|
||||
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
|
||||
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// Tags. listTags returns the roster with per-tag document counts; createTag is
|
||||
// idempotent on name (returns the existing tag if it already exists);
|
||||
// updateTag recolors/renames; deleteTag removes it (assignments cascade).
|
||||
// assignTag/unassignTag link a tag to one document.
|
||||
listTags: () => req<Tag[]>('/tags'),
|
||||
createTag: (name: string, color: TagColor) =>
|
||||
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
|
||||
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
|
||||
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
|
||||
assignTag: (docId: string, tagId: string) =>
|
||||
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
|
||||
unassignTag: (docId: string, tagId: string) =>
|
||||
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
|
||||
|
||||
// Current deployed build id — changes whenever a new frontend ships. The
|
||||
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
||||
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||
}
|
||||
|
||||
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
|
||||
// by splitSnippet to render the matched span highlighted.
|
||||
export const SNIPPET_HL_START = String.fromCharCode(1)
|
||||
export const SNIPPET_HL_END = String.fromCharCode(2)
|
||||
|
||||
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
|
||||
// UI can bold the matched span(s) without dangerously setting innerHTML.
|
||||
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
|
||||
const out: { text: string; hit: boolean }[] = []
|
||||
let rest = snippet
|
||||
for (;;) {
|
||||
const start = rest.indexOf(SNIPPET_HL_START)
|
||||
if (start < 0) {
|
||||
if (rest) out.push({ text: rest, hit: false })
|
||||
break
|
||||
}
|
||||
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
|
||||
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
|
||||
if (end < 0) {
|
||||
// Unterminated (shouldn't happen) — emit the remainder plainly.
|
||||
out.push({ text: rest.slice(start + 1), hit: false })
|
||||
break
|
||||
}
|
||||
out.push({ text: rest.slice(start + 1, end), hit: true })
|
||||
rest = rest.slice(end + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
|
||||
// back to the rose accent, matching the backend's coercion.
|
||||
export function tagColorVar(color: TagColor | string): string {
|
||||
switch (color) {
|
||||
case 'mint':
|
||||
return 'var(--color-mint)'
|
||||
case 'peach':
|
||||
return 'var(--color-peach)'
|
||||
case 'lavender':
|
||||
return 'var(--color-lavender)'
|
||||
case 'sky':
|
||||
return 'var(--color-sky)'
|
||||
case 'honey':
|
||||
return 'var(--color-honey)'
|
||||
default:
|
||||
return 'var(--color-accent)'
|
||||
}
|
||||
}
|
||||
|
||||
// One turn in an Ask Petal conversation. History lives only in the component —
|
||||
// the server is stateless and re-injects the suggestion context every request.
|
||||
export interface ChatMessage {
|
||||
|
||||
BIN
web/src/assets/sounds/cheer.wav
Normal file
BIN
web/src/assets/sounds/cheer.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/chime.wav
Normal file
BIN
web/src/assets/sounds/chime.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/droplet.wav
Normal file
BIN
web/src/assets/sounds/droplet.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop.wav
Normal file
BIN
web/src/assets/sounds/pop.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/shimmer.wav
Normal file
BIN
web/src/assets/sounds/shimmer.wav
Normal file
Binary file not shown.
174
web/src/audio/sounds.ts
Normal file
174
web/src/audio/sounds.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Petal's cute sound palette. The actual tones live as tiny WAV assets in
|
||||
// ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops,
|
||||
// and soft bells tuned to match the app's gentle look). Vite fingerprints and
|
||||
// bundles them into dist, so they ride along inside the single Go binary; here
|
||||
// we just fetch + decode them once and play them through a Web Audio bus with a
|
||||
// soft master volume. The whole thing is opt-out-able and the mute choice
|
||||
// persists in localStorage so it survives reloads.
|
||||
|
||||
import popUrl from '../assets/sounds/pop.wav'
|
||||
import dropletUrl from '../assets/sounds/droplet.wav'
|
||||
import chimeUrl from '../assets/sounds/chime.wav'
|
||||
import shimmerUrl from '../assets/sounds/shimmer.wav'
|
||||
import cheerUrl from '../assets/sounds/cheer.wav'
|
||||
|
||||
const STORAGE_KEY = 'petal.sound'
|
||||
|
||||
// Master volume — deliberately gentle. These are background delights, not alerts.
|
||||
const MASTER_GAIN = 0.5
|
||||
|
||||
export type SoundName =
|
||||
| 'pop' // a soft bubble pop — the signature "something arrived"
|
||||
| 'droplet' // a single rounded water-drop ping
|
||||
| 'chime' // a gentle two-note bell
|
||||
| 'shimmer' // three quick ascending sparkles
|
||||
| 'cheer' // a happy little major arpeggio (accepts, milestones)
|
||||
|
||||
const SOURCES: Record<SoundName, string> = {
|
||||
pop: popUrl,
|
||||
droplet: dropletUrl,
|
||||
chime: chimeUrl,
|
||||
shimmer: shimmerUrl,
|
||||
cheer: cheerUrl,
|
||||
}
|
||||
|
||||
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 */
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Map an editor suggestion type to its sound, so each kind of help has its own
|
||||
// little voice (grammar pops like a bubble, idioms chime, etc.).
|
||||
const SUGGESTION_SOUND: Record<string, SoundName> = {
|
||||
grammar: 'pop',
|
||||
phrasing: 'droplet',
|
||||
idiom: 'chime',
|
||||
clarity: 'shimmer',
|
||||
voice: 'droplet',
|
||||
}
|
||||
|
||||
export function playSuggestionSound(type: string): void {
|
||||
playSound(SUGGESTION_SOUND[type] ?? 'pop')
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
saveStatus: SaveStatus
|
||||
editTick: number
|
||||
acceptTick: number
|
||||
text: string
|
||||
}
|
||||
|
||||
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
|
||||
@@ -26,8 +27,14 @@ const STORAGE_KEY = 'petal.companion'
|
||||
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
|
||||
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||
// (the choice persists in localStorage).
|
||||
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
|
||||
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick })
|
||||
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Props) {
|
||||
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||
wordCount,
|
||||
saveStatus,
|
||||
editTick,
|
||||
acceptTick,
|
||||
text,
|
||||
})
|
||||
|
||||
const [companionId, setCompanionId] = useState<string>(() => {
|
||||
try {
|
||||
@@ -136,6 +143,8 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
||||
<div
|
||||
role="status"
|
||||
onClick={dismiss}
|
||||
onMouseEnter={holdBubble}
|
||||
onMouseLeave={releaseBubble}
|
||||
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
|
||||
style={{
|
||||
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.' },
|
||||
]
|
||||
|
||||
// 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[] = [
|
||||
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
||||
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
pick,
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
import { playSound } from '../../audio/sounds'
|
||||
|
||||
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
@@ -28,6 +30,9 @@ interface Signals {
|
||||
// suggestion — lets the companion react without a full event bus.
|
||||
editTick: 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.
|
||||
@@ -35,14 +40,29 @@ const IDLE_MS = 75_000 // no edits → kitten naps
|
||||
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
|
||||
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
|
||||
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||
const BUBBLE_MS = 6_500 // how long a bubble lingers
|
||||
const CHEER_MS = 4_500 // shorter for quick cheers
|
||||
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||
// how much there is to read, since she reads both the Mandarin and the English
|
||||
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||
const BUBBLE_MS = 9_000 // baseline for a tip / break
|
||||
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
|
||||
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
|
||||
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
|
||||
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||
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
|
||||
// when the kitten speaks, what mood it shows, and how to pace itself so the
|
||||
// companion feels alive without interrupting. UI-agnostic — returns state only.
|
||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Signals) {
|
||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) {
|
||||
const [mood, setMood] = useState<Mood>('idle')
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||
|
||||
@@ -54,6 +74,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
const nextMilestone = useRef(0) // index into MILESTONES
|
||||
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 moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
@@ -67,15 +96,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
lastProactive.current = t
|
||||
}
|
||||
sleeping.current = false
|
||||
// A soft sound to match the bubble's mood — celebrations cheer, gentle
|
||||
// cheers sparkle, break nudges chime, and tips give a little bubble pop.
|
||||
playSound(
|
||||
opts?.celebrate ? 'cheer' : b.tone === 'cheer' ? 'shimmer' : b.tone === 'break' ? 'chime' : 'pop',
|
||||
)
|
||||
setBubble(b)
|
||||
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||
const dur = readBubbleMs(b)
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
|
||||
moodTimer.current = setTimeout(() => setMood('idle'), dur)
|
||||
}, [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(() => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
@@ -83,6 +136,21 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
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.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
|
||||
@@ -161,14 +229,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
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) {
|
||||
lastTip.current = t
|
||||
say({ ...pick(TIPS), tone: 'tip' }, { proactive: true })
|
||||
say(nextTip(), { proactive: true })
|
||||
}
|
||||
}, 10_000)
|
||||
return () => clearInterval(id)
|
||||
}, [bubble, say])
|
||||
}, [bubble, say, nextTip])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -178,5 +247,5 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
[],
|
||||
)
|
||||
|
||||
return { mood, bubble, dismiss }
|
||||
return { mood, bubble, dismiss, holdBubble, releaseBubble }
|
||||
}
|
||||
|
||||
@@ -1,26 +1,71 @@
|
||||
import type { DocSummary } from '../../api/client'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||
import { DocListItem } from './DocListItem'
|
||||
import { SearchBox } from './SearchBox'
|
||||
import { TagChip } from './TagChip'
|
||||
|
||||
interface Props {
|
||||
docs: DocSummary[]
|
||||
roster: Tag[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onCreate: () => void
|
||||
onDelete: (id: string) => void
|
||||
onToggleTag: (docId: string, tag: Tag) => void
|
||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||
}
|
||||
|
||||
// DocList is the 260px sidebar: the document browser plus a New button.
|
||||
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
|
||||
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
||||
// button, and the document browser (each row showing its tag chips).
|
||||
export function DocList({
|
||||
docs,
|
||||
roster,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onToggleTag,
|
||||
onCreateTag,
|
||||
}: Props) {
|
||||
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||
// disappears from the roster.
|
||||
const [filterId, setFilterId] = useState<string | null>(null)
|
||||
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
|
||||
[docs, activeFilter],
|
||||
)
|
||||
|
||||
// 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])
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full w-[260px] flex-col gap-2 p-3"
|
||||
className="flex h-full w-[280px] flex-col gap-2 p-3"
|
||||
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<SearchBox onSelect={onSelect} />
|
||||
|
||||
{usedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pb-0.5">
|
||||
{usedTags.map((t) => (
|
||||
<TagChip
|
||||
key={t.id}
|
||||
tag={t}
|
||||
count={t.doc_count}
|
||||
active={activeFilter === t.id}
|
||||
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
|
||||
style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
@@ -28,18 +73,21 @@ export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Prop
|
||||
</button>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||
{docs.length === 0 ? (
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
No documents yet.
|
||||
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
|
||||
</p>
|
||||
) : (
|
||||
docs.map((doc) => (
|
||||
filtered.map((doc) => (
|
||||
<DocListItem
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
active={doc.id === selectedId}
|
||||
roster={roster}
|
||||
onSelect={() => onSelect(doc.id)}
|
||||
onDelete={() => onDelete(doc.id)}
|
||||
onToggleTag={onToggleTag}
|
||||
onCreateTag={onCreateTag}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,48 +1,101 @@
|
||||
import type { DocSummary } from '../../api/client'
|
||||
import { useState } from 'react'
|
||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||
import { TagChip } from './TagChip'
|
||||
import { TagPicker } from './TagPicker'
|
||||
|
||||
interface Props {
|
||||
doc: DocSummary
|
||||
active: boolean
|
||||
roster: Tag[]
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
onToggleTag: (docId: string, tag: Tag) => void
|
||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||
}
|
||||
|
||||
// One row in the sidebar: title + word count, a delete affordance on hover, and
|
||||
// a rose wash when it's the open document.
|
||||
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
|
||||
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
|
||||
// a delete affordance on hover, and a rose wash when it's the open document.
|
||||
export function DocListItem({
|
||||
doc,
|
||||
active,
|
||||
roster,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onToggleTag,
|
||||
onCreateTag,
|
||||
}: Props) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5"
|
||||
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: active ? 'var(--color-surface)' : 'transparent',
|
||||
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold"
|
||||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold"
|
||||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||||
>
|
||||
{doc.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
|
||||
on hover for the pointer experience. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Tag document"
|
||||
title="Tags"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPicking((v) => !v)
|
||||
}}
|
||||
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)' }}
|
||||
>
|
||||
{doc.title || 'Untitled'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||||
</div>
|
||||
🏷️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete document"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{picking && (
|
||||
<TagPicker
|
||||
roster={roster}
|
||||
assignedIds={assignedIds}
|
||||
onToggle={(t) => onToggleTag(doc.id, t)}
|
||||
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
|
||||
onClose={() => setPicking(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete document"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{doc.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{doc.tags.map((t) => (
|
||||
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
145
web/src/components/DocList/SearchBox.tsx
Normal file
145
web/src/components/DocList/SearchBox.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, splitSnippet, type SearchResult } from '../../api/client'
|
||||
|
||||
interface Props {
|
||||
// Called when a result is chosen — opens that document.
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 220
|
||||
|
||||
// SearchBox is the cross-document finder at the top of the sidebar. Typing
|
||||
// debounces a full-text query; results drop in below with a highlighted snippet.
|
||||
// Clearing (× or empty) returns the sidebar to the normal document list.
|
||||
export function SearchBox({ onSelect }: Props) {
|
||||
const [q, setQ] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const runRef = useRef(0)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
const term = q.trim()
|
||||
if (!term) {
|
||||
setResults(null)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const run = ++runRef.current
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const hits = await api.search(term)
|
||||
if (run === runRef.current) setResults(hits)
|
||||
} catch (err) {
|
||||
console.error('search failed', err)
|
||||
if (run === runRef.current) setResults([])
|
||||
} finally {
|
||||
if (run === runRef.current) setBusy(false)
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [q])
|
||||
|
||||
const clear = () => {
|
||||
setQ('')
|
||||
setResults(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
🔍
|
||||
</span>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索 · Search"
|
||||
aria-label="Search documents"
|
||||
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
/>
|
||||
{q && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={clear}
|
||||
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center"
|
||||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results !== null && (
|
||||
<div className="petal-search-results flex flex-col gap-0.5">
|
||||
{busy && results.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
查找中… · Searching…
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
没有找到 · No matches
|
||||
</p>
|
||||
) : (
|
||||
results.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(r.id)}
|
||||
className="petal-tap flex flex-col items-start gap-0.5 px-3 py-2 text-left"
|
||||
style={{ borderRadius: 'var(--radius-card)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span
|
||||
className="w-full truncate text-sm font-bold"
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
>
|
||||
{r.title || 'Untitled'}
|
||||
</span>
|
||||
{r.snippet && (
|
||||
<span
|
||||
className="line-clamp-2 text-xs"
|
||||
style={{ color: 'var(--color-muted)', lineHeight: 1.4 }}
|
||||
>
|
||||
{splitSnippet(r.snippet).map((seg, i) =>
|
||||
seg.hit ? (
|
||||
<mark
|
||||
key={i}
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-accent) 35%, white)',
|
||||
color: 'var(--color-plum)',
|
||||
borderRadius: 3,
|
||||
padding: '0 1px',
|
||||
}}
|
||||
>
|
||||
{seg.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{seg.text}</span>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
web/src/components/DocList/TagChip.tsx
Normal file
75
web/src/components/DocList/TagChip.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { tagColorVar, type Tag } from '../../api/client'
|
||||
|
||||
interface Props {
|
||||
tag: Tag
|
||||
// When set, renders an interactive chip (filter toggle); `active` fills it.
|
||||
onClick?: () => void
|
||||
active?: boolean
|
||||
// When set, shows a small × to remove the tag (used on document rows).
|
||||
onRemove?: () => void
|
||||
// Optional trailing count (used in the filter bar).
|
||||
count?: number
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
// TagChip is the pill used everywhere a tag appears: on document rows, in the
|
||||
// filter bar, and in the assign popover. The palette color tints a soft wash; a
|
||||
// filled variant marks an active filter. Keeps tap targets comfortable on touch.
|
||||
export function TagChip({ tag, onClick, active, onRemove, count, size = 'sm' }: Props) {
|
||||
const color = tagColorVar(tag.color)
|
||||
const interactive = !!onClick
|
||||
const pad = size === 'md' ? '0.3rem 0.7rem' : '0.12rem 0.5rem'
|
||||
const font = size === 'md' ? '0.8rem' : '0.7rem'
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={
|
||||
onClick
|
||||
? (e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`petal-tag-chip inline-flex items-center gap-1 whitespace-nowrap font-bold${interactive ? ' cursor-pointer' : ''}`}
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
padding: pad,
|
||||
fontSize: font,
|
||||
lineHeight: 1.2,
|
||||
background: active ? color : 'color-mix(in srgb, ' + color + ' 22%, white)',
|
||||
color: active ? 'white' : 'var(--color-plum)',
|
||||
border: `1px solid ${active ? color : 'color-mix(in srgb, ' + color + ' 45%, white)'}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block shrink-0"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: active ? 'white' : color,
|
||||
}}
|
||||
/>
|
||||
{tag.name}
|
||||
{typeof count === 'number' && (
|
||||
<span style={{ opacity: 0.7, fontWeight: 700 }}>{count}</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${tag.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
className="ml-0.5 inline-flex items-center justify-center"
|
||||
style={{ color: 'inherit', opacity: 0.65, fontSize: '0.85em', lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
139
web/src/components/DocList/TagPicker.tsx
Normal file
139
web/src/components/DocList/TagPicker.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
||||
|
||||
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
|
||||
|
||||
interface Props {
|
||||
roster: Tag[]
|
||||
assignedIds: Set<string>
|
||||
onToggle: (tag: Tag) => void
|
||||
onCreate: (name: string, color: TagColor) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// TagPicker is the small popover for managing one document's tags: tap an
|
||||
// existing tag to attach/detach it, or type a new name (with a color swatch) to
|
||||
// create-and-attach. Closes on outside click or Escape.
|
||||
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
|
||||
const [name, setName] = useState('')
|
||||
const [color, setColor] = useState<TagColor>('rose')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
// Defer so the opening click doesn't immediately close it.
|
||||
const id = setTimeout(() => document.addEventListener('pointerdown', onDown), 0)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
clearTimeout(id)
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const submit = () => {
|
||||
const n = name.trim()
|
||||
if (!n) return
|
||||
onCreate(n, color)
|
||||
setName('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="petal-tag-picker absolute z-20 flex w-60 flex-col gap-2 p-3"
|
||||
style={{
|
||||
top: 'calc(100% + 4px)',
|
||||
right: 0,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
标签 · Tags
|
||||
</div>
|
||||
|
||||
{roster.length > 0 && (
|
||||
<div className="flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
|
||||
{roster.map((t) => {
|
||||
const on = assignedIds.has(t.id)
|
||||
const c = tagColorVar(t.color)
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(t)}
|
||||
className="petal-tap inline-flex items-center gap-1 whitespace-nowrap text-xs font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
padding: '0.25rem 0.6rem',
|
||||
background: on ? c : 'color-mix(in srgb, ' + c + ' 18%, white)',
|
||||
color: on ? 'white' : 'var(--color-plum)',
|
||||
border: `1px solid ${on ? c : 'color-mix(in srgb, ' + c + ' 40%, white)'}`,
|
||||
}}
|
||||
>
|
||||
{on && <span aria-hidden>✓</span>}
|
||||
{t.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5 pt-1" style={{ borderTop: '1px solid var(--color-border)' }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={`Color ${c}`}
|
||||
onClick={() => setColor(c)}
|
||||
className="petal-tap-sm"
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: tagColorVar(c),
|
||||
border: color === c ? '2px solid var(--color-plum)' : '2px solid transparent',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}}
|
||||
placeholder="新标签 · New tag"
|
||||
aria-label="New tag name"
|
||||
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
|
||||
style={{
|
||||
height: 34,
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="petal-tap-sm shrink-0 px-3 text-sm font-bold text-white"
|
||||
style={{ height: 34, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import { SuggestionCard } from './SuggestionCard'
|
||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||
import { MisspellCard } from './MisspellCard'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
import { WordCard } from './WordCard'
|
||||
import { GlossTip } from './GlossTip'
|
||||
import { SelectionBubble } from './SelectionBubble'
|
||||
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||
import { api, type Suggestion, type WordInfo } from '../../api/client'
|
||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||
|
||||
export interface EditorChange {
|
||||
@@ -50,6 +54,19 @@ interface MisspellState {
|
||||
left: number
|
||||
}
|
||||
|
||||
// The open right-click word popover (definition + synonyms), or null. `info` is
|
||||
// null while the offline lookup is in flight (`loading`); the card shows a
|
||||
// looking-up state until it resolves.
|
||||
interface WordInfoState {
|
||||
word: string
|
||||
from: number
|
||||
to: number
|
||||
top: number
|
||||
left: number
|
||||
loading: boolean
|
||||
info: WordInfo | null
|
||||
}
|
||||
|
||||
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||
// spray up-and-out from a point; each reads its direction from --dx/--dy.
|
||||
const CONFETTI_DOTS = [
|
||||
@@ -92,6 +109,39 @@ interface HoverState {
|
||||
left: number
|
||||
}
|
||||
|
||||
// The inline hover gloss: the word under the resting pointer, its Chinese
|
||||
// translation, the PM range it covers (to dedupe re-fetches), and where to anchor.
|
||||
interface GlossState {
|
||||
word: string
|
||||
gloss: string
|
||||
from: number
|
||||
to: number
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// A non-empty text selection that the rewrite bubble hovers over.
|
||||
interface SelectionState {
|
||||
from: number
|
||||
to: number
|
||||
text: string
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// An in-flight or completed tone-rewrite preview, pinned over the selection it
|
||||
// came from. `from`/`to` are the PM range the accepted rewrite replaces.
|
||||
interface RewriteState {
|
||||
from: number
|
||||
to: number
|
||||
original: string
|
||||
style: string
|
||||
top: number
|
||||
left: number
|
||||
status: RewriteStatus
|
||||
rewrite: string
|
||||
}
|
||||
|
||||
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||
// alignment, a placeholder, character counting, and the suggestion-highlight
|
||||
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||
@@ -112,6 +162,10 @@ export function EditorCore({
|
||||
const [hover, setHover] = useState<HoverState | null>(null)
|
||||
// The open spelling popover (click a red-underlined word), or null.
|
||||
const [misspell, setMisspell] = useState<MisspellState | null>(null)
|
||||
// The open right-click word popover (definition + synonyms), or null.
|
||||
const [wordInfo, setWordInfo] = useState<WordInfoState | null>(null)
|
||||
// Token to discard a word lookup whose popover has since closed/changed.
|
||||
const wordReqRef = useRef(0)
|
||||
// Transient confetti burst played at the last accept location.
|
||||
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
@@ -120,6 +174,15 @@ export function EditorCore({
|
||||
// While the Ask Petal panel is expanded the card is pinned: the hover-close
|
||||
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
|
||||
const [pinned, setPinned] = useState(false)
|
||||
// Inline hover gloss (rest the pointer on a word → its Chinese meaning).
|
||||
const [gloss, setGloss] = useState<GlossState | null>(null)
|
||||
const glossTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const glossReqRef = useRef(0)
|
||||
// The rewrite affordances: a bubble over the current selection, and the
|
||||
// preview that replaces it once a style is chosen.
|
||||
const [selection, setSelection] = useState<SelectionState | null>(null)
|
||||
const [rewrite, setRewrite] = useState<RewriteState | null>(null)
|
||||
const rewriteReqRef = useRef(0)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -137,14 +200,40 @@ export function EditorCore({
|
||||
},
|
||||
onFocus: () => onFocusMode?.(),
|
||||
onUpdate: ({ editor }) => {
|
||||
// Any edit shifts positions, stranding the spelling popover's anchor.
|
||||
// Any edit shifts positions, stranding the popover anchors.
|
||||
setMisspell(null)
|
||||
setWordInfo(null)
|
||||
setGloss(null)
|
||||
setSelection(null)
|
||||
// A stale rewrite preview points at a range that just moved — drop it.
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
onChange({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
content_text: editor.getText(),
|
||||
word_count: editor.storage.characterCount.words(),
|
||||
})
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
// A selection supersedes the hover gloss; an empty one clears the bubble.
|
||||
setGloss(null)
|
||||
const { from, to, empty } = editor.state.selection
|
||||
const wrapper = wrapperRef.current
|
||||
if (empty || !wrapper) {
|
||||
setSelection(null)
|
||||
return
|
||||
}
|
||||
const text = editor.state.doc.textBetween(from, to, ' ').trim()
|
||||
if (!text) {
|
||||
setSelection(null)
|
||||
return
|
||||
}
|
||||
const start = editor.view.coordsAtPos(from)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 360))
|
||||
const top = start.top - wrapRect.top
|
||||
setSelection({ from, to, text, top, left })
|
||||
},
|
||||
})
|
||||
|
||||
// When the selected document changes, swap in its content without emitting an
|
||||
@@ -154,6 +243,11 @@ export function EditorCore({
|
||||
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||
setHover(null)
|
||||
setMisspell(null)
|
||||
setWordInfo(null)
|
||||
setGloss(null)
|
||||
setSelection(null)
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docId, editor])
|
||||
|
||||
@@ -186,6 +280,8 @@ export function EditorCore({
|
||||
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
|
||||
)
|
||||
const top = elRect.bottom - wrapRect.top + 6
|
||||
// A suggestion card and the word popover shouldn't stack.
|
||||
setWordInfo(null)
|
||||
setHover((prev) => {
|
||||
// Moving to a different highlight resets any Ask Petal pin.
|
||||
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
|
||||
@@ -266,8 +362,17 @@ export function EditorCore({
|
||||
// Click a red-underlined word to open its spelling popover, anchored under the
|
||||
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
||||
// misspelling appearing elsewhere); nspell supplies the corrections.
|
||||
//
|
||||
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
|
||||
// no hover, so the tap is the only way in (mouse users still get hover).
|
||||
const handleSpellClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||
if (suggestionEl) {
|
||||
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||
if (id) openCardFor(id, suggestionEl)
|
||||
return
|
||||
}
|
||||
if (!editor || !spellChecker) return
|
||||
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
||||
if (!target) return
|
||||
@@ -284,9 +389,10 @@ export function EditorCore({
|
||||
const top = elRect.bottom - wrapRect.top + 6
|
||||
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
||||
closeCard()
|
||||
setWordInfo(null)
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
},
|
||||
[editor, spellChecker, closeCard],
|
||||
[editor, spellChecker, closeCard, openCardFor],
|
||||
)
|
||||
|
||||
const replaceMisspelling = useCallback(
|
||||
@@ -304,6 +410,209 @@ export function EditorCore({
|
||||
setMisspell(null)
|
||||
}, [misspell, onAddWord])
|
||||
|
||||
// Right-click a word to look it up: resolve the exact word span under the
|
||||
// pointer, anchor a popover beneath it, and kick off the offline lookup. The
|
||||
// card opens immediately in a loading state and fills in when the (local)
|
||||
// lookup returns. Right-clicking off any word falls through to the native menu.
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return
|
||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||
if (!coords) return
|
||||
const range = wordAt(editor.state.doc, coords.pos)
|
||||
if (!range) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
e.preventDefault()
|
||||
// Anchor under the word itself (not the click point) so the card lines up
|
||||
// with the text the way the spelling popover does.
|
||||
const start = editor.view.coordsAtPos(range.from)
|
||||
const end = editor.view.coordsAtPos(range.to)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const cardWidth = 300
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||
const top = end.bottom - wrapRect.top + 6
|
||||
// Opening a word lookup supersedes any suggestion/spelling card.
|
||||
closeCard()
|
||||
setMisspell(null)
|
||||
const token = ++wordReqRef.current
|
||||
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null })
|
||||
api
|
||||
.lookupWord(range.word)
|
||||
.then((info) => {
|
||||
if (token === wordReqRef.current) {
|
||||
setWordInfo((w) => (w ? { ...w, loading: false, info } : null))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('word lookup failed', err)
|
||||
if (token === wordReqRef.current) {
|
||||
setWordInfo((w) => (w ? { ...w, loading: false, info: null } : null))
|
||||
}
|
||||
})
|
||||
},
|
||||
[editor, closeCard],
|
||||
)
|
||||
|
||||
const replaceWord = useCallback(
|
||||
(synonym: string) => {
|
||||
if (editor && wordInfo) {
|
||||
editor.chain().focus().insertContentAt({ from: wordInfo.from, to: wordInfo.to }, synonym).run()
|
||||
}
|
||||
setWordInfo(null)
|
||||
},
|
||||
[editor, wordInfo],
|
||||
)
|
||||
|
||||
// Hover gloss: rest the pointer on an English word and, after a short delay,
|
||||
// show its Chinese meaning from the offline gloss dataset. Suppressed while a
|
||||
// selection, preview, or another popover is active, or over a word that has its
|
||||
// own affordance (a suggestion highlight / a misspelling).
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return
|
||||
if (selection || rewrite || misspell || wordInfo || pinned) return
|
||||
if (!editor.state.selection.empty) return
|
||||
const t = e.target as HTMLElement
|
||||
const clear = () => {
|
||||
clearTimeout(glossTimer.current)
|
||||
glossReqRef.current++
|
||||
setGloss(null)
|
||||
}
|
||||
if (t.closest('.petal-suggestion') || t.closest('.petal-misspelling')) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||
if (!coords) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
const range = wordAt(editor.state.doc, coords.pos)
|
||||
if (!range) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
// Already showing this exact word — leave it be (no flicker on micro-moves).
|
||||
if (gloss && gloss.from === range.from && gloss.to === range.to) return
|
||||
clearTimeout(glossTimer.current)
|
||||
const token = ++glossReqRef.current
|
||||
glossTimer.current = setTimeout(() => {
|
||||
api
|
||||
.glossWord(range.word)
|
||||
.then((g) => {
|
||||
if (token !== glossReqRef.current) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!g.gloss || !wrapper) {
|
||||
setGloss(null)
|
||||
return
|
||||
}
|
||||
const start = editor.view.coordsAtPos(range.from)
|
||||
const end = editor.view.coordsAtPos(range.to)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
|
||||
const top = end.bottom - wrapRect.top + 6
|
||||
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
|
||||
})
|
||||
.catch(() => {
|
||||
if (token === glossReqRef.current) setGloss(null)
|
||||
})
|
||||
}, 350)
|
||||
},
|
||||
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
|
||||
)
|
||||
|
||||
// Leaving the editor surface drops any pending/shown gloss.
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
clearTimeout(glossTimer.current)
|
||||
glossReqRef.current++
|
||||
setGloss(null)
|
||||
}, [])
|
||||
|
||||
// runRewrite fires the LLM rewrite for a captured range + style and tracks it
|
||||
// through loading → ready/error. A request token discards a response whose
|
||||
// preview has since been cancelled or superseded.
|
||||
const runRewrite = useCallback(
|
||||
(from: number, to: number, original: string, style: string, top: number, left: number) => {
|
||||
const token = ++rewriteReqRef.current
|
||||
setRewrite({ from, to, original, style, top, left, status: 'loading', rewrite: '' })
|
||||
api
|
||||
.rewriteSelection(docId, original, style)
|
||||
.then((res) => {
|
||||
if (token === rewriteReqRef.current) {
|
||||
setRewrite((r) => (r ? { ...r, status: 'ready', rewrite: res.rewrite } : null))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('rewrite failed', err)
|
||||
if (token === rewriteReqRef.current) {
|
||||
setRewrite((r) => (r ? { ...r, status: 'error' } : null))
|
||||
}
|
||||
})
|
||||
},
|
||||
[docId],
|
||||
)
|
||||
|
||||
// Picking a style in the selection bubble: capture the selection's range +
|
||||
// text, anchor a preview below it, and kick off the rewrite.
|
||||
const handleRewrite = useCallback(
|
||||
(style: string) => {
|
||||
if (!editor || !selection) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
const start = editor.view.coordsAtPos(selection.from)
|
||||
const end = editor.view.coordsAtPos(selection.to)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 340))
|
||||
const top = end.bottom - wrapRect.top + 6
|
||||
setSelection(null)
|
||||
setGloss(null)
|
||||
runRewrite(selection.from, selection.to, selection.text, style, top, left)
|
||||
},
|
||||
[editor, selection, runRewrite],
|
||||
)
|
||||
|
||||
const acceptRewrite = useCallback(() => {
|
||||
if (editor && rewrite && rewrite.rewrite) {
|
||||
editor.chain().focus().insertContentAt({ from: rewrite.from, to: rewrite.to }, rewrite.rewrite).run()
|
||||
}
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
}, [editor, rewrite])
|
||||
|
||||
const cancelRewrite = useCallback(() => {
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
}, [])
|
||||
|
||||
const retryRewrite = useCallback(() => {
|
||||
if (rewrite) runRewrite(rewrite.from, rewrite.to, rewrite.original, rewrite.style, rewrite.top, rewrite.left)
|
||||
}, [rewrite, runRewrite])
|
||||
|
||||
// A pointer-down outside the rewrite preview dismisses it.
|
||||
useEffect(() => {
|
||||
if (!rewrite) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.petal-rewrite-card')) return
|
||||
rewriteReqRef.current++
|
||||
setRewrite(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [rewrite])
|
||||
|
||||
// A pointer-down outside the word popover (and not on another word, which would
|
||||
// reopen it via the context menu) closes it.
|
||||
useEffect(() => {
|
||||
if (!wordInfo) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.petal-word-card')) return
|
||||
setWordInfo(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [wordInfo])
|
||||
|
||||
// A pointer-down outside the popover (and not on another misspelling, which
|
||||
// would reopen it) closes the spelling card.
|
||||
useEffect(() => {
|
||||
@@ -320,6 +629,7 @@ export function EditorCore({
|
||||
useEffect(() => () => {
|
||||
clearTimeout(closeTimer.current)
|
||||
clearTimeout(confettiTimer.current)
|
||||
clearTimeout(glossTimer.current)
|
||||
}, [])
|
||||
|
||||
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
||||
@@ -333,6 +643,20 @@ export function EditorCore({
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [pinned, closeCard])
|
||||
|
||||
// Touch has no hover, so the card is opened by a tap and can't close itself on
|
||||
// mouse-leave. Whenever a card is open, a pointer-down outside both the card and
|
||||
// any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
|
||||
// another highlight reopen for that one (via the click handler) without flicker.
|
||||
useEffect(() => {
|
||||
if (!hover) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
const t = e.target as HTMLElement
|
||||
if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
return () => document.removeEventListener('pointerdown', onDown)
|
||||
}, [hover, closeCard])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
||||
@@ -341,10 +665,41 @@ export function EditorCore({
|
||||
className="relative flex-1"
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleSpellClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
|
||||
{selection && !rewrite && (
|
||||
<SelectionBubble
|
||||
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||
onRewrite={handleRewrite}
|
||||
/>
|
||||
)}
|
||||
{rewrite && (
|
||||
<RewritePreview
|
||||
style={rewrite.style}
|
||||
status={rewrite.status}
|
||||
original={rewrite.original}
|
||||
rewrite={rewrite.rewrite}
|
||||
cardStyle={{ top: rewrite.top, left: rewrite.left }}
|
||||
onAccept={acceptRewrite}
|
||||
onCancel={cancelRewrite}
|
||||
onRetry={retryRewrite}
|
||||
/>
|
||||
)}
|
||||
{wordInfo && (
|
||||
<WordCard
|
||||
word={wordInfo.word}
|
||||
info={wordInfo.info}
|
||||
loading={wordInfo.loading}
|
||||
style={{ top: wordInfo.top, left: wordInfo.left }}
|
||||
onReplace={replaceWord}
|
||||
/>
|
||||
)}
|
||||
{misspell && (
|
||||
<MisspellCard
|
||||
word={misspell.word}
|
||||
|
||||
32
web/src/components/Editor/GlossTip.tsx
Normal file
32
web/src/components/Editor/GlossTip.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// GlossTip is the inline hover gloss: rest the pointer on an English word and a
|
||||
// small bubble shows its Chinese meaning, pulled instantly from the offline
|
||||
// gloss dataset. It's a pure reading aid — pointer-events are off so it never
|
||||
// steals hover or blocks a click, and it sits just under the word. Bilingual
|
||||
// chrome elsewhere puts zh first; here the gloss IS the content (the word is
|
||||
// already on screen), so the bubble shows only the 中文.
|
||||
|
||||
interface Props {
|
||||
gloss: string
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export function GlossTip({ gloss, style }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
|
||||
role="tooltip"
|
||||
style={{
|
||||
maxWidth: 280,
|
||||
background: 'var(--color-plum)',
|
||||
color: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
lineHeight: 1.35,
|
||||
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{gloss}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
web/src/components/Editor/RewritePreview.tsx
Normal file
136
web/src/components/Editor/RewritePreview.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { REWRITE_STYLES } from './SelectionBubble'
|
||||
|
||||
// RewritePreview shows the result of a tone-rewrite before it touches the
|
||||
// document: the writer's original passage, the model's rewrite beneath it, and
|
||||
// accept/cancel. It mirrors the SuggestionCard's warm, bilingual chrome. While
|
||||
// the model runs it shows a breathing dot; on failure it offers a gentle retry.
|
||||
|
||||
export type RewriteStatus = 'loading' | 'ready' | 'error'
|
||||
|
||||
interface Props {
|
||||
style: string // the chosen rewrite style key (for the header label)
|
||||
status: RewriteStatus
|
||||
original: string
|
||||
rewrite: string
|
||||
cardStyle: React.CSSProperties
|
||||
onAccept: () => void
|
||||
onCancel: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
|
||||
export function RewritePreview({
|
||||
style,
|
||||
status,
|
||||
original,
|
||||
rewrite,
|
||||
cardStyle,
|
||||
onAccept,
|
||||
onCancel,
|
||||
onRetry,
|
||||
}: Props) {
|
||||
const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="petal-rewrite-card absolute z-30 p-3.5 text-sm"
|
||||
role="dialog"
|
||||
aria-label="Rewrite preview"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 340,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
fontFamily: CJK,
|
||||
...cardStyle,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
<span aria-hidden>{meta.emoji}</span>
|
||||
{meta.zh} · {meta.en}
|
||||
</span>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||
改写 · Rewrite
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Original passage, dimmed — what's being replaced. */}
|
||||
<p
|
||||
className="mt-3 leading-snug"
|
||||
style={{ color: 'var(--color-muted)', textDecoration: 'line-through', textDecorationColor: 'var(--color-border)' }}
|
||||
>
|
||||
{original}
|
||||
</p>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
|
||||
<span
|
||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
改写中… · Rewriting…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="mt-3">
|
||||
<p className="leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
改写失败,请再试一次 · Couldn’t rewrite — try again
|
||||
</p>
|
||||
<div className="mt-2.5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
取消 · Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded-full px-3 py-1 text-xs font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
重试 · Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'ready' && (
|
||||
<>
|
||||
<p className="mt-2 leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
{rewrite}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
取消 · Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
className="rounded-full px-3 py-1 text-xs font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
用这个 · Use this
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
web/src/components/Editor/SelectionBubble.tsx
Normal file
85
web/src/components/Editor/SelectionBubble.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// SelectionBubble floats above a text selection and offers to rewrite it: a
|
||||
// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术,
|
||||
// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up
|
||||
// to EditorCore, which calls the LLM and shows a preview. Buttons use
|
||||
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
|
||||
// before the handler captures its range.
|
||||
|
||||
export interface RewriteStyle {
|
||||
value: string
|
||||
emoji: string
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
|
||||
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
|
||||
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
|
||||
export const REWRITE_STYLES: RewriteStyle[] = [
|
||||
{ value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' },
|
||||
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
||||
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
||||
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
||||
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
||||
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
||||
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
style: React.CSSProperties
|
||||
onRewrite: (style: string) => void
|
||||
}
|
||||
|
||||
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
|
||||
export function SelectionBubble({ style, onRewrite }: Props) {
|
||||
const [natural, ...tones] = REWRITE_STYLES
|
||||
|
||||
return (
|
||||
<div
|
||||
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
|
||||
role="toolbar"
|
||||
aria-label="Rewrite the selection"
|
||||
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
fontFamily: CJK,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRewrite(natural.value)}
|
||||
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
title="Rewrite the selection to sound more natural"
|
||||
>
|
||||
<span aria-hidden>{natural.emoji}</span>
|
||||
<span>{natural.zh}</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
|
||||
{natural.en}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
||||
|
||||
{tones.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => onRewrite(t.value)}
|
||||
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
||||
>
|
||||
<span aria-hidden>{t.emoji}</span>
|
||||
<span>{t.zh}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
web/src/components/Editor/ToneSelect.tsx
Normal file
119
web/src/components/Editor/ToneSelect.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// ToneSelect lets the writer set the document's target tone, which steers the
|
||||
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
|
||||
// journal). A small custom dropdown (not a native <select>) so it can carry the
|
||||
// bilingual zh·en labels and emoji that match Petal's chrome — the writer uses
|
||||
// Mandarin and English. The `value` strings mirror the backend's tone keys.
|
||||
|
||||
export interface ToneOption {
|
||||
value: string
|
||||
emoji: string
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
|
||||
// Keep these `value`s in sync with llm.toneGuidance on the server. 'general'
|
||||
// means no steering (Petal's default friendly ESL advice).
|
||||
export const TONES: ToneOption[] = [
|
||||
{ value: 'general', emoji: '🌸', zh: '通用', en: 'General' },
|
||||
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
||||
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
||||
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
||||
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
||||
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
||||
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function ToneSelect({ value, onChange }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||
|
||||
// Click outside closes the menu.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Document tone"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
title="Set the tone — Petal tailors its advice to match"
|
||||
>
|
||||
<span aria-hidden>{current.emoji}</span>
|
||||
<span>{current.zh}</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· {current.en}</span>
|
||||
<span aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||
⌄
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
||||
style={{
|
||||
width: 200,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
{TONES.map((t) => {
|
||||
const active = t.value === value
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
onChange(t.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
|
||||
style={{
|
||||
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = active ? 'var(--color-surface-alt)' : 'transparent')
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{t.emoji}</span>
|
||||
<span>{t.zh}</span>
|
||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||
{t.en}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
web/src/components/Editor/WordCard.tsx
Normal file
130
web/src/components/Editor/WordCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { WordInfo } from '../../api/client'
|
||||
|
||||
// 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
|
||||
// in place. Both datasets are offline, so this opens instantly and fills in as
|
||||
// the (local) lookup returns. Labels are bilingual (zh-first, en subtitle) to
|
||||
// match the rest of Petal's chrome — the writer uses Mandarin and English.
|
||||
|
||||
interface Props {
|
||||
word: string
|
||||
info: WordInfo | null
|
||||
loading: boolean
|
||||
style: React.CSSProperties
|
||||
onReplace: (synonym: string) => void
|
||||
}
|
||||
|
||||
export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
||||
const definitions = info?.definitions ?? []
|
||||
const synonyms = info?.synonyms ?? []
|
||||
const gloss = info?.gloss ?? ''
|
||||
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={`Definition and synonyms for ${word}`}
|
||||
className="petal-word-card absolute z-20 p-3.5 text-sm"
|
||||
style={{
|
||||
width: 300,
|
||||
maxHeight: 340,
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
词语 · Word
|
||||
</span>
|
||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||
{word}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
|
||||
{gloss && (
|
||||
<p
|
||||
className="mt-2.5 leading-snug"
|
||||
style={{
|
||||
color: 'var(--color-plum)',
|
||||
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
|
||||
}}
|
||||
>
|
||||
{gloss}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
|
||||
<span
|
||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
查找中… · Looking up…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{definitions.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
释义 · Definition
|
||||
</p>
|
||||
<ol className="space-y-1.5">
|
||||
{definitions.map((m, i) => (
|
||||
<li key={i} className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
{m.part_of_speech && (
|
||||
<span className="mr-1 italic" style={{ color: 'var(--color-accent-hover)' }}>
|
||||
{m.part_of_speech}
|
||||
</span>
|
||||
)}
|
||||
{m.definition}
|
||||
{m.example && (
|
||||
<span className="mt-0.5 block italic" style={{ color: 'var(--color-muted)' }}>
|
||||
“{m.example}”
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{synonyms.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
近义词 · Synonyms <span className="font-normal">(点击替换 · tap to swap)</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{synonyms.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onReplace(s)}
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{empty && (
|
||||
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
没有找到这个词 · Nothing found for this word
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
web/src/components/Export/ExportMenu.tsx
Normal file
120
web/src/components/Export/ExportMenu.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type ExportFormat } from '../../api/client'
|
||||
|
||||
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
|
||||
// plain <a download> links to the server's export endpoint (which sets the
|
||||
// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the
|
||||
// browser print dialog against the print stylesheet, so PDF stays CJK-safe with
|
||||
// no server-side font embedding. Bilingual zh·en labels match Petal's chrome.
|
||||
|
||||
interface FormatOption {
|
||||
format: ExportFormat
|
||||
emoji: string
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
|
||||
const FORMATS: FormatOption[] = [
|
||||
{ format: 'docx', emoji: '📄', zh: 'Word 文档', en: 'Word (.docx)' },
|
||||
{ format: 'md', emoji: '📝', zh: 'Markdown', en: 'Markdown (.md)' },
|
||||
{ format: 'html', emoji: '🌐', zh: '网页', en: 'Web page (.html)' },
|
||||
{ format: 'txt', emoji: '🧾', zh: '纯文本', en: 'Plain text (.txt)' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
docId: string
|
||||
}
|
||||
|
||||
export function ExportMenu({ docId }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Export document"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
title="Save or print your writing"
|
||||
>
|
||||
<span aria-hidden>⬇</span>
|
||||
<span>导出</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
||||
style={{
|
||||
width: 220,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
{FORMATS.map((f) => (
|
||||
<a
|
||||
key={f.format}
|
||||
role="menuitem"
|
||||
href={api.exportUrl(docId, f.format)}
|
||||
download
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold no-underline"
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span aria-hidden>{f.emoji}</span>
|
||||
<span>{f.zh}</span>
|
||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||
{f.en}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
|
||||
<div className="my-1 h-px" style={{ background: 'var(--color-border)' }} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
// Defer so the menu unmounts before the print dialog snapshots.
|
||||
setTimeout(() => window.print(), 50)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
|
||||
style={{ background: 'transparent', color: 'var(--color-plum)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span aria-hidden>🖨️</span>
|
||||
<span>打印 / PDF</span>
|
||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||
Print / PDF
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
230
web/src/components/History/HistoryPanel.tsx
Normal file
230
web/src/components/History/HistoryPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, type Document, type DocumentVersion } from '../../api/client'
|
||||
|
||||
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
|
||||
// document, newest first, with a one-click preview and restore. It's the safety
|
||||
// net that makes trusting Petal with real writing reasonable — a bad edit or a
|
||||
// regretted rewrite is always recoverable, and restoring is itself undoable
|
||||
// (the server keeps a pre_restore copy). Bilingual, zh-first, to match chrome.
|
||||
|
||||
interface Props {
|
||||
docId: string
|
||||
onClose: () => void
|
||||
// Called after a successful restore with the freshly-restored document so the
|
||||
// editor can reload it.
|
||||
onRestored: (doc: Document) => void
|
||||
}
|
||||
|
||||
// kindLabel maps a snapshot kind to its bilingual badge + accent color.
|
||||
const KIND: Record<DocumentVersion['kind'], { zh: string; en: string; color: string }> = {
|
||||
manual: { zh: '保存点', en: 'Saved point', color: 'var(--color-accent)' },
|
||||
auto: { zh: '自动', en: 'Auto', color: 'var(--color-muted)' },
|
||||
pre_restore: { zh: '恢复前', en: 'Before restore', color: 'var(--color-lavender)' },
|
||||
}
|
||||
|
||||
// relativeTime renders a UTC timestamp as a gentle "x minutes ago" string.
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime()
|
||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
||||
if (secs < 60) return 'just now · 刚刚'
|
||||
const mins = Math.round(secs / 60)
|
||||
if (mins < 60) return `${mins} min ago · ${mins} 分钟前`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs} hr ago · ${hrs} 小时前`
|
||||
const days = Math.round(hrs / 24)
|
||||
return `${days} day${days > 1 ? 's' : ''} ago · ${days} 天前`
|
||||
}
|
||||
|
||||
export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
const [versions, setVersions] = useState<DocumentVersion[] | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
||||
const [preview, setPreview] = useState<DocumentVersion | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false)
|
||||
try {
|
||||
setVersions(await api.listVersions(docId))
|
||||
} catch {
|
||||
setError(true)
|
||||
}
|
||||
}, [docId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Escape closes the drawer.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const choose = useCallback(
|
||||
async (v: DocumentVersion) => {
|
||||
setSelected(v)
|
||||
setPreview(null)
|
||||
try {
|
||||
setPreview(await api.getVersion(docId, v.id))
|
||||
} catch {
|
||||
setPreview(null)
|
||||
}
|
||||
},
|
||||
[docId],
|
||||
)
|
||||
|
||||
const restore = useCallback(async () => {
|
||||
if (!selected) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const doc = await api.restoreVersion(docId, selected.id)
|
||||
onRestored(doc)
|
||||
onClose()
|
||||
} catch {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [docId, selected, onRestored, onClose])
|
||||
|
||||
return (
|
||||
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<aside
|
||||
className="relative flex h-full w-full max-w-[380px] flex-col"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
<header
|
||||
className="flex items-center justify-between px-5 py-4"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div>
|
||||
<div className="text-base font-extrabold text-plum">历史 · History</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
Every saved moment — nothing is ever lost 🌸
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close history"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-lg"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
{error ? (
|
||||
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
Couldn’t load history just now.
|
||||
<button onClick={load} className="ml-1 font-bold text-plum underline">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : versions === null ? (
|
||||
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
Loading…
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="px-2 py-8 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
No snapshots yet. Keep writing — Petal saves restore points as you go.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{versions.map((v) => {
|
||||
const k = KIND[v.kind]
|
||||
const active = selected?.id === v.id
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose(v)}
|
||||
className="flex w-full flex-col gap-1 rounded-2xl px-3 py-2.5 text-left"
|
||||
style={{
|
||||
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--color-border)' : 'transparent'}`,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background = 'var(--color-surface-alt)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = active
|
||||
? 'var(--color-surface-alt)'
|
||||
: 'transparent')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-bold text-plum">{v.title || 'Untitled'}</span>
|
||||
<span
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||
style={{ background: k.color, color: '#fff' }}
|
||||
>
|
||||
{k.zh} · {k.en}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
<span>{relativeTime(v.created_at)}</span>
|
||||
<span>{v.word_count} words</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
className="shrink-0 px-4 py-3"
|
||||
style={{ borderTop: '1px solid var(--color-border)', background: 'var(--color-surface-alt)' }}
|
||||
>
|
||||
<div className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
预览 · Preview
|
||||
</div>
|
||||
<div
|
||||
className="mb-3 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-xl px-3 py-2 text-sm"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
fontFamily: 'var(--font-body)',
|
||||
}}
|
||||
>
|
||||
{preview ? preview.content_text || '(empty)' : 'Loading…'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={restore}
|
||||
disabled={busy}
|
||||
className="w-full rounded-full py-2.5 text-sm font-extrabold text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
{busy ? 'Restoring…' : '恢复这个版本 · Restore this version'}
|
||||
</button>
|
||||
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||
Your current draft is saved first, so this is undoable.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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, playSound, 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) playSound('pop') // 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="rounded-full px-1.5 py-0.5 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>
|
||||
)
|
||||
}
|
||||
81
web/src/components/StatusBar/StatsPanel.tsx
Normal file
81
web/src/components/StatusBar/StatsPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react'
|
||||
import { computeStats, gradeBand } from './stats'
|
||||
|
||||
// StatsPanel is the popover that opens above the word count: a small grid of
|
||||
// writing statistics computed from the live document. Bilingual zh·en labels to
|
||||
// match Petal's chrome. Reading level shows a friendly band, not just a number.
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
wordCount: number
|
||||
}
|
||||
|
||||
interface Row {
|
||||
zh: string
|
||||
en: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function StatsPanel({ text, wordCount }: Props) {
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
const s = computeStats(text, wordCount)
|
||||
const band = gradeBand(s.gradeLevel)
|
||||
const fmt = (n: number, d = 0) =>
|
||||
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||
return [
|
||||
{ zh: '字数', en: 'Words', value: fmt(s.words) },
|
||||
{ zh: '字符', en: 'Characters', value: fmt(s.characters) },
|
||||
{ zh: '句子', en: 'Sentences', value: fmt(s.sentences) },
|
||||
{ zh: '段落', en: 'Paragraphs', value: fmt(s.paragraphs) },
|
||||
{ zh: '页数', en: 'Pages', value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
|
||||
{ zh: '阅读时间', en: 'Reading time', value: readingTime(s.readingTimeMin) },
|
||||
{ zh: '平均词长', en: 'Avg word length', value: `${fmt(s.avgWordLength, 1)}` },
|
||||
{ zh: '词汇丰富度', en: 'Word variety', value: `${fmt(s.variety * 100)}%` },
|
||||
{
|
||||
zh: '阅读难度',
|
||||
en: 'Reading level',
|
||||
value: s.words > 0 ? `${band.en} · ${fmt(s.gradeLevel, 1)}` : '—',
|
||||
},
|
||||
]
|
||||
}, [text, wordCount])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Writing statistics"
|
||||
className="petal-word-card absolute bottom-7 left-0 z-30 p-3.5"
|
||||
style={{
|
||||
width: 268,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
>
|
||||
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
写作统计 · Writing stats
|
||||
</p>
|
||||
<dl className="space-y-1.5">
|
||||
{rows.map((r) => (
|
||||
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<dt style={{ color: 'var(--color-muted)' }}>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
|
||||
{r.zh}
|
||||
</span>{' '}
|
||||
{r.en}
|
||||
</dt>
|
||||
<dd className="font-bold tabular-nums">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// readingTime renders minutes as a friendly "< 1 min" / "N min" string.
|
||||
function readingTime(min: number): string {
|
||||
if (min <= 0) return '0 min'
|
||||
if (min < 1) return '< 1 min'
|
||||
return `${Math.round(min)} min`
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import { StatsPanel } from './StatsPanel'
|
||||
import { SoundToggle } from './SoundToggle'
|
||||
|
||||
interface Props {
|
||||
wordCount: number
|
||||
// Live plain text of the document, for the expanded stats panel.
|
||||
text: string
|
||||
saveStatus: SaveStatus
|
||||
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||
checking: boolean
|
||||
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||
voicing: boolean
|
||||
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
||||
// (the writing still saves locally, so this is awareness, not an error).
|
||||
llmDown: boolean
|
||||
}
|
||||
|
||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
@@ -20,16 +28,44 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
// StatusBar is the slim footer: word count on the left, save state and the
|
||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||
export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmDown }: Props) {
|
||||
const label = SAVE_LABEL[saveStatus]
|
||||
// The expanded stats panel toggles open when the word count is clicked.
|
||||
const [statsOpen, setStatsOpen] = useState(false)
|
||||
const statsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!statsOpen) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!statsRef.current?.contains(e.target as Node)) setStatsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [statsOpen])
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
|
||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
<span>
|
||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||
</span>
|
||||
<div className="relative" ref={statsRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsOpen((o) => !o)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={statsOpen}
|
||||
className="rounded-full px-1.5 py-0.5 font-semibold transition-colors"
|
||||
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = statsOpen ? 'var(--color-accent-hover)' : 'inherit')
|
||||
}
|
||||
title="Writing stats"
|
||||
>
|
||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||
</button>
|
||||
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
||||
</div>
|
||||
{checking && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
@@ -54,6 +90,20 @@ export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{llmDown && !checking && !voicing && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
title="Petal can't reach its writing helper right now — your text is still saved."
|
||||
style={{ color: 'var(--color-honey)' }}
|
||||
>
|
||||
<span aria-hidden>🌙</span>
|
||||
<span>小助手在休息</span>
|
||||
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{label && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
@@ -71,6 +121,9 @@ export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<SoundToggle />
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
92
web/src/components/StatusBar/stats.ts
Normal file
92
web/src/components/StatusBar/stats.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Writing statistics computed from the document's plain text. These power the
|
||||
// expanded stats panel that opens when the writer clicks the word count. The
|
||||
// reading-level formulas are English-centric (Flesch); a document mixing in
|
||||
// Mandarin still gets sensible counts, with the level treated as approximate.
|
||||
|
||||
export interface WritingStats {
|
||||
words: number
|
||||
characters: number
|
||||
charactersNoSpaces: number
|
||||
sentences: number
|
||||
paragraphs: number
|
||||
pages: number
|
||||
avgWordLength: number // letters per English word
|
||||
uniqueWords: number
|
||||
variety: number // type-token ratio, 0–1 (unique ÷ total English words)
|
||||
readingTimeMin: number
|
||||
gradeLevel: number // Flesch–Kincaid grade
|
||||
}
|
||||
|
||||
// English word tokens (letters with internal apostrophes/hyphens) — used for the
|
||||
// letter-length, syllable, and variety measures, which only make sense for
|
||||
// alphabetic words.
|
||||
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g
|
||||
// Sentence terminators, including the CJK fullwidth forms.
|
||||
const SENTENCE_RE = /[.!?。!?]+/g
|
||||
// Words per page (a rough double-spaced manuscript page) and reading speed.
|
||||
const WORDS_PER_PAGE = 250
|
||||
const WORDS_PER_MINUTE = 200
|
||||
|
||||
// countSyllables is the common vowel-group heuristic: count vowel runs, drop a
|
||||
// trailing silent "e"/"es"/"ed", and floor at one. Not perfect, but plenty
|
||||
// accurate for an at-a-glance reading level.
|
||||
function countSyllables(word: string): number {
|
||||
const w = word.toLowerCase().replace(/[^a-z]/g, '')
|
||||
if (w.length === 0) return 0
|
||||
if (w.length <= 3) return 1
|
||||
const trimmed = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').replace(/^y/, '')
|
||||
const groups = trimmed.match(/[aeiouy]{1,2}/g)
|
||||
return groups ? groups.length : 1
|
||||
}
|
||||
|
||||
export function computeStats(text: string, wordCount: number): WritingStats {
|
||||
const trimmed = text.trim()
|
||||
const characters = [...text].length
|
||||
const charactersNoSpaces = text.replace(/\s/g, '').length
|
||||
|
||||
const sentenceMatches = trimmed.match(SENTENCE_RE)
|
||||
const sentences = sentenceMatches ? sentenceMatches.length : trimmed ? 1 : 0
|
||||
|
||||
const paragraphBlocks = trimmed.split(/\n{2,}/).filter((p) => p.trim().length > 0)
|
||||
const paragraphs = paragraphBlocks.length
|
||||
|
||||
const englishWords = trimmed.match(ENGLISH_WORD_RE) ?? []
|
||||
const letters = englishWords.reduce((sum, w) => sum + w.length, 0)
|
||||
const avgWordLength = englishWords.length > 0 ? letters / englishWords.length : 0
|
||||
|
||||
const unique = new Set(englishWords.map((w) => w.toLowerCase()))
|
||||
const uniqueWords = unique.size
|
||||
const variety = englishWords.length > 0 ? uniqueWords / englishWords.length : 0
|
||||
|
||||
const syllables = englishWords.reduce((sum, w) => sum + countSyllables(w), 0)
|
||||
// Flesch–Kincaid grade level; needs at least one sentence and word to be real.
|
||||
let gradeLevel = 0
|
||||
if (englishWords.length > 0 && sentences > 0) {
|
||||
gradeLevel =
|
||||
0.39 * (englishWords.length / sentences) + 11.8 * (syllables / englishWords.length) - 15.59
|
||||
if (gradeLevel < 0) gradeLevel = 0
|
||||
}
|
||||
|
||||
return {
|
||||
words: wordCount,
|
||||
characters,
|
||||
charactersNoSpaces,
|
||||
sentences,
|
||||
paragraphs,
|
||||
pages: wordCount / WORDS_PER_PAGE,
|
||||
avgWordLength,
|
||||
uniqueWords,
|
||||
variety,
|
||||
readingTimeMin: wordCount / WORDS_PER_MINUTE,
|
||||
gradeLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// gradeBand turns a Flesch–Kincaid grade into a friendly bilingual descriptor —
|
||||
// far more useful to an ESL writer than a bare number.
|
||||
export function gradeBand(grade: number): { zh: string; en: string } {
|
||||
if (grade <= 5) return { zh: '简单', en: 'Easy' }
|
||||
if (grade <= 8) return { zh: '标准', en: 'Standard' }
|
||||
if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' }
|
||||
return { zh: '较难', en: 'Advanced' }
|
||||
}
|
||||
267
web/src/effects/PetalFall.tsx
Normal file
267
web/src/effects/PetalFall.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a
|
||||
// handful of soft flower petals down the page. It sits behind everything,
|
||||
// ignores pointer events, and stays sparse so it reads as "occasional petals on
|
||||
// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering
|
||||
// nothing. No external libraries — a single tiny shader program animates every
|
||||
// petal entirely on the GPU, so the CPU just spins a clock.
|
||||
|
||||
// Each petal is a quad whose corners we expand in the vertex shader. We pack one
|
||||
// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus
|
||||
// the petal's static randomness (lane, speed, size, sway, tint, spin).
|
||||
const PETAL_COUNT = 26
|
||||
|
||||
// Soft Petal-palette tints (rose / blush / lavender), as RGB 0..1.
|
||||
const TINTS: [number, number, number][] = [
|
||||
[0.91, 0.63, 0.75], // accent rose #E8A0BF
|
||||
[1.0, 0.94, 0.96], // near-white blush
|
||||
[0.96, 0.72, 0.63], // peach #F4B8A0
|
||||
[0.77, 0.71, 0.91], // lavender #C5B4E8
|
||||
[0.85, 0.55, 0.69], // deeper rose
|
||||
]
|
||||
|
||||
const VERT = `
|
||||
precision mediump float;
|
||||
attribute vec2 a_corner; // quad corner in [-0.5,0.5]
|
||||
attribute vec4 a_rand; // x: lane(0..1), y: speed, z: size, w: phase
|
||||
attribute vec4 a_rand2; // x: swayAmp, y: swayFreq, z: spin, w: tintIdx
|
||||
uniform float u_time;
|
||||
uniform vec2 u_res;
|
||||
varying vec2 v_uv;
|
||||
varying float v_tint;
|
||||
varying float v_alpha;
|
||||
|
||||
void main() {
|
||||
v_uv = a_corner + 0.5;
|
||||
v_tint = a_rand2.w;
|
||||
|
||||
float lane = a_rand.x;
|
||||
float speed = a_rand.y;
|
||||
float size = a_rand.z;
|
||||
float phase = a_rand.w;
|
||||
|
||||
// Vertical fall: loop 0..1 over time, offset per petal so they don't sync.
|
||||
float fall = fract(u_time * speed + phase);
|
||||
// Map to clip space: start above the top (1.15) fall to below bottom (-1.15).
|
||||
float y = 1.15 - fall * 2.3;
|
||||
|
||||
// Horizontal: a base lane plus a gentle sway as it descends.
|
||||
float sway = a_rand2.x * sin(u_time * a_rand2.y + phase * 6.2831);
|
||||
float x = (lane * 2.0 - 1.0) + sway;
|
||||
|
||||
// Spin the quad over time.
|
||||
float ang = u_time * a_rand2.z + phase * 6.2831;
|
||||
float c = cos(ang), s = sin(ang);
|
||||
vec2 corner = a_corner * size;
|
||||
// Keep petals visually square despite aspect ratio.
|
||||
corner.x *= u_res.y / u_res.x;
|
||||
vec2 rot = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
|
||||
|
||||
// Fade in near the top and out near the bottom so they don't pop.
|
||||
v_alpha = smoothstep(0.0, 0.12, fall) * (1.0 - smoothstep(0.86, 1.0, fall));
|
||||
|
||||
gl_Position = vec4(x + rot.x, y + rot.y, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `
|
||||
precision mediump float;
|
||||
varying vec2 v_uv;
|
||||
varying float v_tint;
|
||||
varying float v_alpha;
|
||||
uniform vec3 u_tints[5];
|
||||
|
||||
// A soft petal/teardrop alpha mask centered in the quad.
|
||||
float petalMask(vec2 uv) {
|
||||
vec2 p = uv - vec2(0.5);
|
||||
// Teardrop: pinch the top, round the bottom.
|
||||
float r = length(vec2(p.x * 1.65, p.y * 1.15 + 0.12));
|
||||
float body = smoothstep(0.5, 0.16, r);
|
||||
// A subtle crease down the middle for a petal feel.
|
||||
float crease = 1.0 - 0.18 * exp(-pow(p.x * 7.0, 2.0));
|
||||
return body * crease;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float m = petalMask(v_uv);
|
||||
if (m < 0.01) discard;
|
||||
int idx = int(v_tint + 0.5);
|
||||
vec3 col = u_tints[0];
|
||||
if (idx == 1) col = u_tints[1];
|
||||
else if (idx == 2) col = u_tints[2];
|
||||
else if (idx == 3) col = u_tints[3];
|
||||
else if (idx == 4) col = u_tints[4];
|
||||
gl_FragColor = vec4(col, m * v_alpha * 0.55);
|
||||
}
|
||||
`
|
||||
|
||||
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
|
||||
const sh = gl.createShader(type)
|
||||
if (!sh) return null
|
||||
gl.shaderSource(sh, src)
|
||||
gl.compileShader(sh)
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
console.error('petal shader compile failed', gl.getShaderInfoLog(sh))
|
||||
gl.deleteShader(sh)
|
||||
return null
|
||||
}
|
||||
return sh
|
||||
}
|
||||
|
||||
// Deterministic-ish pseudo random (we can't use Math.random at module scope for
|
||||
// SSR safety, but here in an effect it's fine; kept simple).
|
||||
function rand(): number {
|
||||
return Math.random()
|
||||
}
|
||||
|
||||
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 canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true })
|
||||
if (!gl) return // no WebGL — silently skip the effect
|
||||
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
|
||||
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)
|
||||
if (!vs || !fs) return
|
||||
const prog = gl.createProgram()!
|
||||
gl.attachShader(prog, vs)
|
||||
gl.attachShader(prog, fs)
|
||||
gl.linkProgram(prog)
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
console.error('petal program link failed', gl.getProgramInfoLog(prog))
|
||||
return
|
||||
}
|
||||
gl.useProgram(prog)
|
||||
|
||||
// Build interleaved vertex data: 6 verts per petal, each with corner(2),
|
||||
// rand(4), rand2(4) = 10 floats.
|
||||
const FLOATS_PER_VERT = 10
|
||||
const corners = [
|
||||
[-0.5, -0.5],
|
||||
[0.5, -0.5],
|
||||
[-0.5, 0.5],
|
||||
[-0.5, 0.5],
|
||||
[0.5, -0.5],
|
||||
[0.5, 0.5],
|
||||
]
|
||||
const data = new Float32Array(PETAL_COUNT * 6 * FLOATS_PER_VERT)
|
||||
let o = 0
|
||||
for (let i = 0; i < PETAL_COUNT; i++) {
|
||||
const lane = rand()
|
||||
const speed = 0.018 + rand() * 0.03 // slow, gentle fall
|
||||
const size = 0.05 + rand() * 0.06
|
||||
const phase = rand()
|
||||
const swayAmp = 0.04 + rand() * 0.09
|
||||
const swayFreq = 0.4 + rand() * 0.7
|
||||
const spin = (rand() - 0.5) * 1.2
|
||||
const tint = Math.floor(rand() * TINTS.length)
|
||||
for (let c = 0; c < 6; c++) {
|
||||
data[o++] = corners[c][0]
|
||||
data[o++] = corners[c][1]
|
||||
data[o++] = lane
|
||||
data[o++] = speed
|
||||
data[o++] = size
|
||||
data[o++] = phase
|
||||
data[o++] = swayAmp
|
||||
data[o++] = swayFreq
|
||||
data[o++] = spin
|
||||
data[o++] = tint
|
||||
}
|
||||
}
|
||||
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
|
||||
|
||||
const stride = FLOATS_PER_VERT * 4
|
||||
const aCorner = gl.getAttribLocation(prog, 'a_corner')
|
||||
const aRand = gl.getAttribLocation(prog, 'a_rand')
|
||||
const aRand2 = gl.getAttribLocation(prog, 'a_rand2')
|
||||
gl.enableVertexAttribArray(aCorner)
|
||||
gl.vertexAttribPointer(aCorner, 2, gl.FLOAT, false, stride, 0)
|
||||
gl.enableVertexAttribArray(aRand)
|
||||
gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, stride, 2 * 4)
|
||||
gl.enableVertexAttribArray(aRand2)
|
||||
gl.vertexAttribPointer(aRand2, 4, gl.FLOAT, false, stride, 6 * 4)
|
||||
|
||||
const uTime = gl.getUniformLocation(prog, 'u_time')
|
||||
const uRes = gl.getUniformLocation(prog, 'u_res')
|
||||
const uTints = gl.getUniformLocation(prog, 'u_tints')
|
||||
gl.uniform3fv(uTints, TINTS.flat())
|
||||
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.disable(gl.DEPTH_TEST)
|
||||
|
||||
// Non-null aliases so the render closures keep the narrowed types.
|
||||
const view = canvas
|
||||
const glc = gl
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
function resize() {
|
||||
const w = Math.floor(window.innerWidth * dpr)
|
||||
const h = Math.floor(window.innerHeight * dpr)
|
||||
if (view.width !== w || view.height !== h) {
|
||||
view.width = w
|
||||
view.height = h
|
||||
}
|
||||
glc.viewport(0, 0, view.width, view.height)
|
||||
glc.uniform2f(uRes, view.width, view.height)
|
||||
}
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
let raf = 0
|
||||
let running = true
|
||||
const start = performance.now()
|
||||
function frame() {
|
||||
if (!running) return
|
||||
const t = (performance.now() - start) / 1000
|
||||
glc.clearColor(0, 0, 0, 0)
|
||||
glc.clear(glc.COLOR_BUFFER_BIT)
|
||||
glc.uniform1f(uTime, t)
|
||||
glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6)
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
// Pause when the tab is hidden to save the battery.
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
} else if (!running) {
|
||||
running = true
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
raf = requestAnimationFrame(frame)
|
||||
|
||||
return () => {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', resize)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
gl.deleteBuffer(buf)
|
||||
gl.deleteProgram(prog)
|
||||
gl.deleteShader(vs)
|
||||
gl.deleteShader(fs)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
className="petal-fall pointer-events-none fixed inset-0"
|
||||
style={{ zIndex: 20 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const [checking, setChecking] = useState(false)
|
||||
// True while a whole-document voice pass is in flight (explicit user action).
|
||||
const [voicing, setVoicing] = useState(false)
|
||||
// True when the last LLM pass couldn't reach the model (server 502 or network
|
||||
// error). Drives a gentle, reassuring "helper is resting" note — the writing
|
||||
// itself still saves fine, so this is awareness, not an error. Cleared on the
|
||||
// next success or doc switch.
|
||||
const [llmDown, setLlmDown] = useState(false)
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const docIdRef = useRef(docId)
|
||||
@@ -30,9 +35,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const fresh = await api.checkDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(fresh)
|
||||
setLlmDown(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('checkpoint failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
} finally {
|
||||
if (run === runRef.current) setChecking(false)
|
||||
}
|
||||
@@ -50,9 +57,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
const full = await api.voiceDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(full)
|
||||
setLlmDown(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('voice pass failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
} finally {
|
||||
if (run === runRef.current) setVoicing(false)
|
||||
}
|
||||
@@ -72,6 +81,7 @@ export function useCheckpoint(docId: string | null) {
|
||||
setSuggestions([])
|
||||
setChecking(false)
|
||||
setVoicing(false)
|
||||
setLlmDown(false)
|
||||
if (!docId) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -94,5 +104,5 @@ export function useCheckpoint(docId: string | null) {
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
|
||||
return { suggestions, checking, voicing, llmDown, schedule, runVoice, removeSuggestion }
|
||||
}
|
||||
|
||||
63
web/src/hooks/useTags.ts
Normal file
63
web/src/hooks/useTags.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, type Tag, type TagColor } from '../api/client'
|
||||
|
||||
// useTags owns the user's tag roster (the full set the writer has created, with
|
||||
// per-tag document counts). Document↔tag assignments live in App alongside the
|
||||
// document list; this hook is just the roster plus create/recolor/delete and a
|
||||
// refresh used to keep counts current after an assignment changes.
|
||||
export function useTags() {
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setTags(await api.listTags())
|
||||
} catch (err) {
|
||||
console.error('failed to load tags', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Create (or reuse) a tag, returning it. The roster is refreshed so the new
|
||||
// tag appears with a zero count.
|
||||
const createTag = useCallback(
|
||||
async (name: string, color: TagColor): Promise<Tag | null> => {
|
||||
try {
|
||||
const tag = await api.createTag(name, color)
|
||||
await refresh()
|
||||
return tag
|
||||
} catch (err) {
|
||||
console.error('failed to create tag', err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
)
|
||||
|
||||
const recolorTag = useCallback(
|
||||
async (id: string, color: TagColor) => {
|
||||
// Optimistic recolor; refresh reconciles.
|
||||
setTags((prev) => prev.map((t) => (t.id === id ? { ...t, color } : t)))
|
||||
try {
|
||||
await api.updateTag(id, { color })
|
||||
} catch (err) {
|
||||
console.error('failed to recolor tag', err)
|
||||
void refresh()
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
)
|
||||
|
||||
const deleteTag = useCallback(async (id: string) => {
|
||||
setTags((prev) => prev.filter((t) => t.id !== id))
|
||||
try {
|
||||
await api.deleteTag(id)
|
||||
} catch (err) {
|
||||
console.error('failed to delete tag', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { tags, refresh, createTag, recolorTag, deleteTag }
|
||||
}
|
||||
@@ -156,6 +156,25 @@ button, a, input {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* --- Word lookup popover ----------------------------------------------------
|
||||
Right-click a word for its dictionary definition and synonyms. Same soft card
|
||||
treatment as the spelling popover; synonym pills swap the word on click. */
|
||||
.petal-word-card {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* --- ESL superpowers (Phase 9) ----------------------------------------------
|
||||
Inline Chinese gloss on hover (a small dark tooltip under the word), and the
|
||||
selection rewrite bubble + its preview card. All share the soft pop-in. The
|
||||
gloss tip fades in a touch faster — it's a fleeting reading aid, not a panel. */
|
||||
.petal-gloss-tip {
|
||||
animation: petal-suggestion-in 140ms ease both;
|
||||
}
|
||||
.petal-selection-bubble,
|
||||
.petal-rewrite-card {
|
||||
animation: petal-suggestion-in 200ms ease both;
|
||||
}
|
||||
|
||||
/* --- Accept confetti --------------------------------------------------------
|
||||
A tiny CSS-only burst played where a suggestion is accepted: four colored
|
||||
dots spray up-and-out, then fade. No JS animation — each dot reads its
|
||||
@@ -262,3 +281,155 @@ button, a, input {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* --- Organization & search (Phase 10) --------------------------------------
|
||||
Tag chips, the search results list, and per-row affordances. The row actions
|
||||
(tag / delete) fade in on hover for pointer users but are always visible on
|
||||
touch (no hover) — see the coarse-pointer block below. */
|
||||
.petal-tag-chip {
|
||||
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.petal-search-results {
|
||||
animation: petal-suggestion-in 160ms ease both;
|
||||
}
|
||||
.petal-tag-picker {
|
||||
animation: petal-suggestion-in 160ms ease both;
|
||||
}
|
||||
.petal-row-action {
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease, background 160ms ease;
|
||||
}
|
||||
.group:hover .petal-row-action,
|
||||
.petal-row-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.petal-row-action:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
/* Clamp a search snippet to two lines. */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Touch & tablet polish --------------------------------------------------
|
||||
On coarse pointers (tablets, phones) there's no hover, so make tap targets
|
||||
comfortable and reveal affordances that would otherwise be hover-only. */
|
||||
@media (pointer: coarse) {
|
||||
.petal-tap {
|
||||
min-height: 44px;
|
||||
}
|
||||
.petal-tap-sm {
|
||||
min-height: 36px;
|
||||
}
|
||||
/* Row actions can't rely on hover — keep them visible and roomy. */
|
||||
.petal-row-action {
|
||||
opacity: 1;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
/* Bigger, easier-to-hit suggestion/word/tag pills. */
|
||||
.petal-tag-chip {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Responsive sidebar (narrow screens) ------------------------------------
|
||||
Below the tablet breakpoint the sidebar becomes an overlay drawer toggled by a
|
||||
hamburger in the header, instead of a permanent column. A scrim sits behind it.
|
||||
On wide screens the toggle + scrim are hidden and the sidebar is in-flow. */
|
||||
.petal-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
.petal-scrim {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.petal-sidebar-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
.petal-sidebar {
|
||||
position: fixed;
|
||||
top: 48px; /* below the 12-height header */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
/* On mobile the sidebar is hidden by default; the drawer-open class slides it
|
||||
in. (Distraction-free's petal-sidebar-hidden still wins to keep it closed.) */
|
||||
.petal-sidebar:not(.petal-drawer-open) {
|
||||
width: 0;
|
||||
transform: translateX(-24px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.petal-scrim.petal-scrim-show {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 48px 0 0 0;
|
||||
z-index: 20;
|
||||
background: rgba(61, 46, 57, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
|
||||
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
|
||||
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: 0.85;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
.petal-fall,
|
||||
.petal-no-print,
|
||||
.petal-sidebar,
|
||||
.petal-suggestion-card,
|
||||
.petal-misspell-card,
|
||||
.petal-gloss-tip,
|
||||
.petal-selection-bubble,
|
||||
.petal-rewrite-card,
|
||||
.petal-confetti,
|
||||
.petal-companion {
|
||||
display: none !important;
|
||||
}
|
||||
html, body, #root {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
/* Drop the in-editor highlight underlines/tints — they're guidance, not ink. */
|
||||
.petal-suggestion,
|
||||
.petal-misspelling {
|
||||
background: none !important;
|
||||
text-decoration: none !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
/* Let the writing use the full page width. */
|
||||
.max-w-\[720px\] { max-width: none !important; }
|
||||
}
|
||||
|
||||
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