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