Editor: document tone, right-click word lookup, expanded stats

Four enhancements to make the editor fit real school usage:

- Per-document tone (academic/professional/casual/humorous/creative/
  persuasive/general): new documents.tone column (migration 0002), threaded
  through the docs API, a bilingual ToneSelect dropdown on the title row, and
  injected into the grammar-checkpoint LLM prompt so advice fits the register.
  The voice pass stays tone-agnostic.

- Right-click word lookup: a new offline `lexicon` package serves definitions
  (Wordset, modern ESL-friendly glosses) and synonyms (WordNet synsets first,
  then frequency+stopword-ranked Moby for breadth) from gzipped embedded data,
  behind /api/word/{word} with light morphology. The WordCard popover shows the
  definition and tappable synonym pills that swap the word in place.

- Expanded writing stats: clicking the word count opens a StatsPanel with page
  count, sentences, paragraphs, reading time, average word length, word variety,
  and Flesch-Kincaid reading level — all computed client-side.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 23:22:55 -07:00
parent 95123e8c49
commit 4c288834c0
23 changed files with 1021 additions and 32 deletions

View File

@@ -5,6 +5,7 @@ import { useCheckpoint } from './hooks/useCheckpoint'
import { useSpellChecker } from './hooks/useSpellChecker'
import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { ToneSelect } from './components/Editor/ToneSelect'
import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
@@ -16,6 +17,10 @@ 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.
@@ -49,6 +54,8 @@ export default function App() {
setCurrentDoc(doc)
setTitle(doc.title)
setWordCount(doc.word_count)
setTone(doc.tone || 'general')
setDocText(doc.content_text)
},
[saveNow],
)
@@ -68,6 +75,8 @@ export default function App() {
setCurrentDoc(fresh)
setTitle(fresh.title)
setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
} else {
setDocs(list)
await openDoc(list[0].id)
@@ -90,6 +99,8 @@ export default function App() {
setCurrentDoc(fresh)
setTitle(fresh.title)
setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
}, [saveNow])
const handleDelete = useCallback(
@@ -104,6 +115,8 @@ export default function App() {
setCurrentDoc(null)
setTitle('')
setWordCount(0)
setTone('general')
setDocText('')
}
}
return remaining
@@ -126,6 +139,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 +150,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(
@@ -209,14 +237,17 @@ 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)' }}
/>
<ToneSelect value={tone} onChange={handleToneChange} />
</div>
<EditorCore
key={currentDoc.id}
docId={currentDoc.id}
@@ -236,6 +267,7 @@ export default function App() {
<div onMouseDown={handleChromeDown}>
<StatusBar
wordCount={wordCount}
text={docText}
saveStatus={status}
checking={checking}
voicing={voicing}

View File

@@ -14,6 +14,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,9 +26,25 @@ 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 few definition senses and a list of
// synonyms. Either list may be empty when the word isn't a headword.
export interface WordInfo {
word: string
definitions: WordMeaning[]
synonyms: string[]
}
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
// A single LLM-proposed edit. `original` is the source of truth for placement —
@@ -83,6 +100,9 @@ export const api = {
dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
// Offline word lookup (definition + synonyms) for the right-click popover.
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
// 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' }),

View File

@@ -10,7 +10,8 @@ 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 { api, type Suggestion, type WordInfo } from '../../api/client'
import type { SpellChecker } from '../../hooks/useSpellChecker'
export interface EditorChange {
@@ -50,6 +51,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 = [
@@ -112,6 +126,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)
@@ -137,8 +155,9 @@ 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)
onChange({
content: JSON.stringify(editor.getJSON()),
content_text: editor.getText(),
@@ -154,6 +173,7 @@ export function EditorCore({
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
setHover(null)
setMisspell(null)
setWordInfo(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor])
@@ -186,6 +206,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)
@@ -284,6 +306,7 @@ 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],
@@ -304,6 +327,72 @@ 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],
)
// 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(() => {
@@ -342,9 +431,19 @@ export function EditorCore({
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onClick={handleSpellClick}
onContextMenu={handleContextMenu}
>
<EditorContent editor={editor} className="h-full" />
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{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}

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

View File

@@ -0,0 +1,116 @@
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 empty = !loading && 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>
{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>
)
}

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

View File

@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel'
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
@@ -20,16 +24,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 }: 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>

View 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, 01 (unique ÷ total English words)
readingTimeMin: number
gradeLevel: number // FleschKincaid 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)
// FleschKincaid 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 FleschKincaid 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' }
}

View File

@@ -156,6 +156,13 @@ 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;
}
/* --- 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