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
93 lines
3.6 KiB
TypeScript
93 lines
3.6 KiB
TypeScript
// 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' }
|
||
}
|