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:
@@ -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,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>
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
Reference in New Issue
Block a user