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

@@ -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' }
}