Two editor features that were in flight alongside the sound work: - FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/ Title presets in the toolbar; StatusBar + CSS support - Image handling: internal/images handler, upload route + config, client API, EditorCore wiring, and md/html/docx export support for images Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
|
import { StatsPanel } from './StatsPanel'
|
|
import { SoundToggle } from './SoundToggle'
|
|
|
|
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
|
|
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
|
voicing: boolean
|
|
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
|
// (the writing still saves locally, so this is awareness, not an error).
|
|
llmDown: boolean
|
|
}
|
|
|
|
const SAVE_LABEL: Record<SaveStatus, string> = {
|
|
idle: '',
|
|
pending: 'Editing…',
|
|
saving: 'Saving…',
|
|
saved: 'Saved just now',
|
|
error: "Couldn't save",
|
|
}
|
|
|
|
// 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, text, saveStatus, checking, voicing, llmDown }: 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-11 shrink-0 items-center gap-3 px-6 text-sm"
|
|
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
|
>
|
|
<div className="relative" ref={statsRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setStatsOpen((o) => !o)}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={statsOpen}
|
|
className="rounded-full px-2 py-0.5 text-[0.95rem] font-bold 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>
|
|
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…">
|
|
<span
|
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
|
style={{ background: 'var(--color-accent)' }}
|
|
/>
|
|
Checking…
|
|
</span>
|
|
</>
|
|
)}
|
|
{voicing && (
|
|
<>
|
|
<span aria-hidden>·</span>
|
|
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
|
|
<span
|
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
|
style={{ background: 'var(--color-honey)' }}
|
|
/>
|
|
Reading your voice…
|
|
</span>
|
|
</>
|
|
)}
|
|
{llmDown && !checking && !voicing && (
|
|
<>
|
|
<span aria-hidden>·</span>
|
|
<span
|
|
className="inline-flex items-center gap-1.5"
|
|
title="Petal can't reach its writing helper right now — your text is still saved."
|
|
style={{ color: 'var(--color-honey)' }}
|
|
>
|
|
<span aria-hidden>🌙</span>
|
|
<span>小助手在休息</span>
|
|
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
{label && (
|
|
<>
|
|
<span aria-hidden>·</span>
|
|
<span
|
|
className="inline-flex items-center gap-1.5"
|
|
style={{ color: saveStatus === 'error' ? 'var(--color-accent)' : undefined }}
|
|
>
|
|
{saveStatus === 'saved' && (
|
|
<span
|
|
className="inline-block h-1.5 w-1.5 rounded-full"
|
|
style={{ background: 'var(--color-success)' }}
|
|
/>
|
|
)}
|
|
{label}
|
|
</span>
|
|
</>
|
|
)}
|
|
<div className="ml-auto">
|
|
<SoundToggle />
|
|
</div>
|
|
</footer>
|
|
)
|
|
}
|