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
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
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`
|
|
}
|