The rule pack in prose.ts already found "a apple" — articles, pluralAfterNumber, subjectVerbAgreement, uncountables are all there, and they already surface as real mechanics cards. But mechanicsFindings only ran inside runCheck, behind the same 4s checkpoint debounce as the model, and only reached the screen via the server's reply. A free, instant, offline-capable detection was being delivered on an LLM-shaped delay. The rule pack now runs on its own 250ms fuse and renders its findings with no network at all, as provisional cards. The mechanics submit follows; its reply is authoritative and clears them. If the reply never comes — offline, server down — the cards simply stay, which is the whole point of having rules that need no model. Provisional cards are keyed by wording rather than position, so one can't flicker into a duplicate of its own persisted twin while she types around it. resolveServerId maps a card to the row the API can act on, awaiting the in-flight submit, so accepting inside that window still records the keep and plants its word in the garden instead of being quietly dropped; null means there is no row and the edit has landed regardless. Findings she actions while provisional are remembered client-side, because the detector has no memory between runs. runCheck no longer re-submits what the fast pass already filed — it's the catch-up path for when that submit failed. The arrival chime keys rule-pack cards by wording too, so a finding doesn't chime once as provisional and again as persisted. Not done, deliberately: no distinct style for unconfirmed local hits. The rail renders both engines identically on purpose, and a provisional card now lives for one LAN round-trip. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
642 lines
25 KiB
TypeScript
642 lines
25 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
|
import { useAutoSave } from './hooks/useAutoSave'
|
|
import { findingKey, useCheckpoint } from './hooks/useCheckpoint'
|
|
import { useSpellChecker } from './hooks/useSpellChecker'
|
|
import { useTags } from './hooks/useTags'
|
|
import { DocList } from './components/DocList/DocList'
|
|
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
|
import { ToneSelect } from './components/Editor/ToneSelect'
|
|
import { ChromeStrip } from './components/Editor/ChromeStrip'
|
|
import { ExportMenu } from './components/Export/ExportMenu'
|
|
import { HistoryPanel } from './components/History/HistoryPanel'
|
|
import { GardenPanel } from './components/Garden/GardenPanel'
|
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
|
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
|
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
|
import { SignInOverlay } from './components/Auth/SignInOverlay'
|
|
import { useSession } from './hooks/useSession'
|
|
import { takeDraft } from './lib/drafts'
|
|
import { useVersionWatch } from './hooks/useVersionWatch'
|
|
import { PetalFall } from './effects/PetalFall'
|
|
import { usePack } from './i18n'
|
|
import { useNightMode } from './hooks/useNightMode'
|
|
import { playSuggestionSound } from './audio/sounds'
|
|
|
|
export default function App() {
|
|
const updateAvailable = useVersionWatch()
|
|
// Late-night calm mode: dark theme + falling stars instead of petals. The hook
|
|
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
|
|
// layer so the petals become stars.
|
|
const night = useNightMode()
|
|
// Who's writing, and whether the server still recognises them. `signedOut`
|
|
// flips the moment any call comes back 401.
|
|
const { me, signedOut } = useSession()
|
|
const t = usePack()
|
|
// A real account to sign out of, as opposed to the hardcoded local user a
|
|
// build without auth configured runs as.
|
|
const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null
|
|
const [docs, setDocs] = useState<DocSummary[]>([])
|
|
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
|
const [title, setTitle] = useState('')
|
|
const [wordCount, setWordCount] = useState(0)
|
|
// The current document's target tone (steers checkpoint advice) and its live
|
|
// plain text (drives the expanded writing-stats panel in the StatusBar).
|
|
const [tone, setTone] = useState('general')
|
|
const [docText, setDocText] = useState('')
|
|
const [ready, setReady] = useState(false)
|
|
// Distraction-free mode: entered on editor focus, collapses the doc-list
|
|
// sidebar. Escape or a click outside the editor canvas restores it.
|
|
const [focusMode, setFocusMode] = useState(false)
|
|
// Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled
|
|
// by the header hamburger. Ignored on wide screens (sidebar is always in-flow).
|
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
const canvasRef = useRef<HTMLDivElement>(null)
|
|
// Monotonic counters the companion watches to react to writing + accepts.
|
|
const [editTick, setEditTick] = useState(0)
|
|
const [acceptTick, setAcceptTick] = useState(0)
|
|
// History drawer visibility, and an epoch bumped on restore to force the
|
|
// editor to remount with the restored content (its initialContent is read
|
|
// only on mount).
|
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
const [gardenOpen, setGardenOpen] = useState(false)
|
|
const [editorEpoch, setEditorEpoch] = useState(0)
|
|
|
|
// Live mirrors of the current doc's editable fields so the "discard blank
|
|
// drafts on navigation" logic can read the latest values without rebuilding
|
|
// callbacks on every keystroke.
|
|
const currentDocRef = useRef(currentDoc)
|
|
const titleRef = useRef(title)
|
|
const wordCountRef = useRef(wordCount)
|
|
currentDocRef.current = currentDoc
|
|
titleRef.current = title
|
|
wordCountRef.current = wordCount
|
|
|
|
// A throwaway blank draft: no words and still the default/empty title. We
|
|
// delete these on navigation rather than leave orphan "Untitled" docs behind.
|
|
const isBlankDraft = useCallback(() => {
|
|
const t = titleRef.current.trim()
|
|
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
|
}, [])
|
|
|
|
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
|
const {
|
|
suggestions,
|
|
checking,
|
|
voicing,
|
|
collocating,
|
|
llmDown,
|
|
schedule: scheduleCheckpoint,
|
|
runVoice,
|
|
runCollocation,
|
|
removeSuggestion,
|
|
resolveServerId,
|
|
} = useCheckpoint(currentDoc?.id ?? null)
|
|
// Browser-side spell checker — loads the en-US dictionary once per session.
|
|
const { checker: spellChecker, addWord } = useSpellChecker()
|
|
// The tag roster (with counts). Assignments live on the doc summaries below.
|
|
const { tags: tagRoster, refresh: refreshTags, createTag } = useTags()
|
|
|
|
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
|
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
|
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
|
|
}, [])
|
|
|
|
// Attach or detach a tag on a document, updating the sidebar optimistically and
|
|
// refreshing the roster so its counts stay current. Tags are kept sorted by
|
|
// name to match the server's ordering.
|
|
const setDocTag = useCallback(
|
|
async (docId: string, tag: Tag, attach: boolean) => {
|
|
setDocs((prev) =>
|
|
prev.map((d) => {
|
|
if (d.id !== docId) return d
|
|
const without = d.tags.filter((t) => t.id !== tag.id)
|
|
const next = attach ? [...without, tag] : without
|
|
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
|
return { ...d, tags: next }
|
|
}),
|
|
)
|
|
try {
|
|
if (attach) await api.assignTag(docId, tag.id)
|
|
else await api.unassignTag(docId, tag.id)
|
|
void refreshTags()
|
|
} catch (err) {
|
|
console.error('tag assignment failed', err)
|
|
void refreshTags()
|
|
}
|
|
},
|
|
[refreshTags],
|
|
)
|
|
|
|
const handleToggleTag = useCallback(
|
|
(docId: string, tag: Tag) => {
|
|
const doc = docs.find((d) => d.id === docId)
|
|
const has = doc?.tags.some((t) => t.id === tag.id) ?? false
|
|
void setDocTag(docId, tag, !has)
|
|
},
|
|
[docs, setDocTag],
|
|
)
|
|
|
|
const handleCreateTag = useCallback(
|
|
async (docId: string, name: string, color: TagColor) => {
|
|
const tag = await createTag(name, color)
|
|
if (tag) void setDocTag(docId, tag, true)
|
|
},
|
|
[createTag, setDocTag],
|
|
)
|
|
|
|
// If the session lapsed while she was writing, the body that couldn't be
|
|
// saved was stashed on this device. Opening the document again is where it
|
|
// comes back: the stashed fields win over the server's older copy, and a save
|
|
// is scheduled straight away so it stops being local-only. Version history
|
|
// makes this safe to do silently — the server's copy is one restore away.
|
|
const pendingRescueRef = useRef<{ id: string; body: DocUpdate } | null>(null)
|
|
const rescueDraft = useCallback((doc: Document): Document => {
|
|
const stashed = takeDraft(doc.id)
|
|
if (!stashed) return doc
|
|
const patch = Object.fromEntries(
|
|
Object.entries(stashed.body).filter(([, v]) => v !== undefined),
|
|
)
|
|
if (Object.keys(patch).length === 0) return doc
|
|
const merged = { ...doc, ...patch } as Document
|
|
if (merged.content === doc.content && merged.title === doc.title) return doc
|
|
// Save it, but only once this really is the open document — the auto-save
|
|
// writes to whichever doc is current when its timer fires.
|
|
pendingRescueRef.current = { id: doc.id, body: stashed.body }
|
|
return merged
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const rescued = pendingRescueRef.current
|
|
if (rescued && currentDoc?.id === rescued.id) {
|
|
pendingRescueRef.current = null
|
|
schedule(rescued.body)
|
|
}
|
|
}, [currentDoc?.id, schedule])
|
|
|
|
const openDoc = useCallback(
|
|
async (id: string) => {
|
|
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
|
// Decide the leaving doc's fate before any await mutates state.
|
|
const leaving = currentDocRef.current
|
|
const leavingBlank = isBlankDraft()
|
|
await saveNow() // flush any pending edits to the doc we're leaving
|
|
const doc = rescueDraft(await api.getDoc(id))
|
|
setCurrentDoc(doc)
|
|
setTitle(doc.title)
|
|
setWordCount(doc.word_count)
|
|
setTone(doc.tone || 'general')
|
|
setDocText(doc.content_text)
|
|
// Leaving an untouched blank draft for a different doc? Drop it silently
|
|
// (best-effort cleanup — a 404 just means it's already gone).
|
|
if (leaving && leaving.id !== id && leavingBlank) {
|
|
api.deleteDoc(leaving.id).catch(() => {})
|
|
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
|
|
}
|
|
},
|
|
[saveNow, isBlankDraft],
|
|
)
|
|
|
|
// Initial load: fetch the list, opening the first doc (or creating one).
|
|
const bootedRef = useRef(false)
|
|
useEffect(() => {
|
|
if (bootedRef.current) return
|
|
bootedRef.current = true
|
|
;(async () => {
|
|
try {
|
|
let list = await api.listDocs()
|
|
if (list.length === 0) {
|
|
const fresh = await api.createDoc()
|
|
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }]
|
|
setDocs(list)
|
|
setCurrentDoc(fresh)
|
|
setTitle(fresh.title)
|
|
setWordCount(0)
|
|
setTone(fresh.tone || 'general')
|
|
setDocText('')
|
|
} else {
|
|
setDocs(list)
|
|
await openDoc(list[0].id)
|
|
}
|
|
} catch (err) {
|
|
console.error('failed to load documents', err)
|
|
} finally {
|
|
setReady(true)
|
|
}
|
|
})()
|
|
}, [openDoc])
|
|
|
|
const handleCreate = useCallback(async () => {
|
|
// Already sitting on a fresh blank draft? Reuse it instead of stacking
|
|
// another empty "Untitled" on top.
|
|
if (currentDocRef.current && isBlankDraft()) return
|
|
await saveNow()
|
|
const fresh = await api.createDoc()
|
|
setDocs((prev) => [
|
|
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
|
|
...prev,
|
|
])
|
|
setCurrentDoc(fresh)
|
|
setTitle(fresh.title)
|
|
setWordCount(0)
|
|
setTone(fresh.tone || 'general')
|
|
setDocText('')
|
|
}, [saveNow, isBlankDraft])
|
|
|
|
// Duplicate a document: copy its body/tone into a fresh doc under the pack's
|
|
// "copy" title,
|
|
// then open the copy. Tags aren't carried over (a fresh start for the copy).
|
|
const handleDuplicate = useCallback(
|
|
async (id: string) => {
|
|
try {
|
|
await saveNow() // flush in case we're duplicating the open doc
|
|
const src = await api.getDoc(id)
|
|
const fresh = await api.createDoc()
|
|
const dupTitle = t.app.duplicateTitle(src.title?.trim() || 'Untitled')
|
|
const updated = await api.updateDoc(fresh.id, {
|
|
title: dupTitle,
|
|
content: src.content,
|
|
content_text: src.content_text,
|
|
tone: src.tone,
|
|
word_count: src.word_count,
|
|
})
|
|
setDocs((prev) => [
|
|
{ id: updated.id, title: updated.title, word_count: updated.word_count, updated_at: updated.updated_at, tags: [] },
|
|
...prev,
|
|
])
|
|
setCurrentDoc(updated)
|
|
setTitle(updated.title)
|
|
setWordCount(updated.word_count)
|
|
setTone(updated.tone || 'general')
|
|
setDocText(updated.content_text)
|
|
} catch (err) {
|
|
console.error('duplicate failed', err)
|
|
}
|
|
},
|
|
[saveNow],
|
|
)
|
|
|
|
const handleDelete = useCallback(
|
|
async (id: string) => {
|
|
await api.deleteDoc(id)
|
|
setDocs((prev) => {
|
|
const remaining = prev.filter((d) => d.id !== id)
|
|
if (currentDoc?.id === id) {
|
|
if (remaining.length > 0) {
|
|
void openDoc(remaining[0].id)
|
|
} else {
|
|
setCurrentDoc(null)
|
|
setTitle('')
|
|
setWordCount(0)
|
|
setTone('general')
|
|
setDocText('')
|
|
}
|
|
}
|
|
return remaining
|
|
})
|
|
},
|
|
[currentDoc, openDoc],
|
|
)
|
|
|
|
const handleTitleChange = useCallback(
|
|
(value: string) => {
|
|
setTitle(value)
|
|
if (currentDoc) {
|
|
patchSummary(currentDoc.id, { title: value })
|
|
schedule({ title: value })
|
|
}
|
|
},
|
|
[currentDoc, patchSummary, schedule],
|
|
)
|
|
|
|
// She took the kitten up on its daily invitation. The prompt becomes the
|
|
// blank page's title, so the question she agreed to answer stays in front of
|
|
// her while she answers it — rather than being said once and then gone the
|
|
// moment the bubble fades.
|
|
const handleAcceptInvitation = useCallback(
|
|
(prompt: string) => {
|
|
if (!currentDoc) return
|
|
handleTitleChange(prompt)
|
|
},
|
|
[currentDoc, handleTitleChange],
|
|
)
|
|
|
|
const handleEditorChange = useCallback(
|
|
(change: EditorChange) => {
|
|
setWordCount(change.word_count)
|
|
setDocText(change.content_text)
|
|
setEditTick((n) => n + 1)
|
|
if (currentDoc) {
|
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
|
schedule(change)
|
|
scheduleCheckpoint(change.content_text)
|
|
}
|
|
},
|
|
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
|
)
|
|
|
|
// Changing the tone persists it and re-runs the checkpoint so Petal's advice
|
|
// re-tunes to the new register. The auto-save (1.5s) lands before the
|
|
// checkpoint debounce (4s), so the server reads the updated tone.
|
|
const handleToneChange = useCallback(
|
|
(value: string) => {
|
|
setTone(value)
|
|
if (currentDoc) {
|
|
schedule({ tone: value })
|
|
scheduleCheckpoint()
|
|
}
|
|
},
|
|
[currentDoc, schedule, scheduleCheckpoint],
|
|
)
|
|
|
|
// Accept applies the replacement in the editor (handled in EditorCore) and
|
|
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
|
|
// A rule-pack card can be accepted before its row exists — the edit has already
|
|
// landed either way, so a missing id just means there's nothing to file.
|
|
const handleAccept = useCallback(
|
|
async (s: Suggestion) => {
|
|
removeSuggestion(s.id)
|
|
setAcceptTick((n) => n + 1)
|
|
try {
|
|
const id = await resolveServerId(s)
|
|
if (id) await api.acceptSuggestion(id)
|
|
} catch (err) {
|
|
console.error('accept failed', err)
|
|
}
|
|
},
|
|
[removeSuggestion, resolveServerId],
|
|
)
|
|
|
|
// After restoring a version, swap the restored doc into the editor. Bumping
|
|
// editorEpoch remounts EditorCore so it picks up the restored content.
|
|
const handleRestored = useCallback(
|
|
(doc: Document) => {
|
|
setCurrentDoc(doc)
|
|
setTitle(doc.title)
|
|
setWordCount(doc.word_count)
|
|
setTone(doc.tone || 'general')
|
|
setDocText(doc.content_text)
|
|
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
|
|
setEditorEpoch((n) => n + 1)
|
|
},
|
|
[patchSummary],
|
|
)
|
|
|
|
// Escape always restores the sidebar while in distraction-free mode.
|
|
useEffect(() => {
|
|
if (!focusMode) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setFocusMode(false)
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [focusMode])
|
|
|
|
// A pointer-down outside the centered editor canvas (the cream gutters, the
|
|
// header, the status bar) also restores the sidebar.
|
|
const handleChromeDown = useCallback((e: React.MouseEvent) => {
|
|
if (!canvasRef.current?.contains(e.target as Node)) setFocusMode(false)
|
|
}, [])
|
|
|
|
const handleDismiss = useCallback(
|
|
async (s: Suggestion) => {
|
|
removeSuggestion(s.id)
|
|
try {
|
|
const id = await resolveServerId(s)
|
|
if (id) await api.dismissSuggestion(id)
|
|
} catch (err) {
|
|
console.error('dismiss failed', err)
|
|
}
|
|
},
|
|
[removeSuggestion, resolveServerId],
|
|
)
|
|
|
|
// Play a soft sound when freshly-checked suggestions arrive — one per distinct
|
|
// new type, lightly staggered so a batch reads as a little melody rather than
|
|
// a pile-up. We track which ids we've already chimed for, and only chime for
|
|
// recently-created suggestions so opening a doc with old pending advice stays
|
|
// silent (the existing set was created in a past session).
|
|
// Rule-pack findings are chimed by their wording, not their id: the same fix
|
|
// appears first as a provisional card and then as its persisted row, and the
|
|
// writer should hear it once.
|
|
const chimedRef = useRef<Set<string>>(new Set())
|
|
useEffect(() => {
|
|
const key = (s: Suggestion) => (s.source === 'local' ? `local:${findingKey(s)}` : s.id)
|
|
const fresh = suggestions.filter((s) => !chimedRef.current.has(key(s)))
|
|
fresh.forEach((s) => chimedRef.current.add(key(s)))
|
|
const justMade = fresh.filter(
|
|
(s) => Date.now() - new Date(s.created_at).getTime() < 12_000,
|
|
)
|
|
if (justMade.length === 0) return
|
|
const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3)
|
|
const timers = types.map((t, i) =>
|
|
window.setTimeout(() => playSuggestionSound(t), i * 150),
|
|
)
|
|
return () => timers.forEach((id) => window.clearTimeout(id))
|
|
}, [suggestions])
|
|
|
|
// Forget chimed ids when switching documents so the set can't grow unbounded.
|
|
useEffect(() => {
|
|
chimedRef.current = new Set()
|
|
}, [currentDoc?.id])
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<PetalFall night={night} />
|
|
<header
|
|
onMouseDown={handleChromeDown}
|
|
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-3 md:px-5"
|
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="Toggle document list"
|
|
onClick={() => {
|
|
setFocusMode(false)
|
|
setDrawerOpen((v) => !v)
|
|
}}
|
|
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
|
|
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
|
|
>
|
|
☰
|
|
</button>
|
|
<span className="text-xl">🌸</span>
|
|
{/* The wordmark is the first thing to go on a small phone. The header
|
|
holds a hamburger, a name and the garden button, and the garden
|
|
button's label is a langpack string: 词汇花园 · Garden is 130px
|
|
where Jardim de palavras · Garden is nearly 200, which is the
|
|
difference between fitting a 320px screen and scrolling the whole
|
|
app sideways. Of everything in this row, the one that can be spared
|
|
is the app's own name — she is already inside the app, and the
|
|
blossom stays. */}
|
|
<span className="petal-wordmark text-lg font-extrabold text-plum">Petal</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setGardenOpen(true)}
|
|
aria-label="Vocabulary garden"
|
|
title="Words you've looked up, blooming for review"
|
|
className="petal-tap-sm ml-auto 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)',
|
|
}}
|
|
>
|
|
<span aria-hidden>🌷</span>
|
|
<span>{t.app.garden}</span>
|
|
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
|
|
</button>
|
|
</header>
|
|
|
|
<div className="relative flex min-h-0 flex-1">
|
|
<div
|
|
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
|
|
>
|
|
<DocList
|
|
docs={docs}
|
|
roster={tagRoster}
|
|
selectedId={currentDoc?.id ?? null}
|
|
onSelect={openDoc}
|
|
onCreate={handleCreate}
|
|
onDelete={handleDelete}
|
|
onDuplicate={handleDuplicate}
|
|
onToggleTag={handleToggleTag}
|
|
onCreateTag={handleCreateTag}
|
|
account={account}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
|
|
onClick={() => setDrawerOpen(false)}
|
|
aria-hidden
|
|
/>
|
|
|
|
<main className="flex min-w-0 flex-1 flex-col">
|
|
{currentDoc ? (
|
|
<>
|
|
<div
|
|
onMouseDown={handleChromeDown}
|
|
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
|
>
|
|
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
|
{/* Title, then the three chrome pills. Their labels are
|
|
bilingual and don't shrink, so how much width this row
|
|
wants is a property of the langpack — 历史 is two glyphs
|
|
where Historique is ten — and on a phone no language's
|
|
version of it fits. The pills therefore live in a strip
|
|
that scrolls itself; the title takes its own line below
|
|
the drawer breakpoint so it keeps its full width. */}
|
|
<div className="mb-5 flex flex-wrap items-center gap-2 md:gap-3">
|
|
<input
|
|
value={title}
|
|
onChange={(e) => handleTitleChange(e.target.value)}
|
|
placeholder="Untitled"
|
|
aria-label="Document title"
|
|
className="min-w-0 flex-1 basis-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none md:basis-0"
|
|
style={{ fontFamily: 'var(--font-ui)' }}
|
|
/>
|
|
<ChromeStrip className="petal-no-print flex items-center gap-2 py-0.5 md:gap-3">
|
|
<ToneSelect value={tone} onChange={handleToneChange} />
|
|
<button
|
|
type="button"
|
|
onClick={() => setHistoryOpen(true)}
|
|
aria-label="Version history"
|
|
title="Browse and restore earlier versions"
|
|
className="inline-flex h-9 shrink-0 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)',
|
|
}}
|
|
>
|
|
<span aria-hidden>🕘</span>
|
|
<span>{t.app.history}</span>
|
|
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
|
</button>
|
|
<ExportMenu docId={currentDoc.id} />
|
|
</ChromeStrip>
|
|
</div>
|
|
<EditorCore
|
|
key={`${currentDoc.id}:${editorEpoch}`}
|
|
docId={currentDoc.id}
|
|
initialContent={currentDoc.content}
|
|
onChange={handleEditorChange}
|
|
suggestions={suggestions}
|
|
onAccept={handleAccept}
|
|
onDismiss={handleDismiss}
|
|
onVoiceCheck={runVoice}
|
|
voicing={voicing}
|
|
onCollocationCheck={runCollocation}
|
|
collocating={collocating}
|
|
onFocusMode={() => setFocusMode(true)}
|
|
spellChecker={spellChecker}
|
|
onAddWord={addWord}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="petal-no-print" onMouseDown={handleChromeDown}>
|
|
<StatusBar
|
|
wordCount={wordCount}
|
|
text={docText}
|
|
saveStatus={status}
|
|
checking={checking}
|
|
voicing={voicing}
|
|
collocating={collocating}
|
|
llmDown={llmDown}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div
|
|
className="flex flex-1 items-center justify-center text-sm"
|
|
style={{ color: 'var(--color-muted)' }}
|
|
>
|
|
{ready ? 'Create a document to start writing.' : 'Loading…'}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{historyOpen && currentDoc && (
|
|
<HistoryPanel
|
|
docId={currentDoc.id}
|
|
onClose={() => setHistoryOpen(false)}
|
|
onRestored={handleRestored}
|
|
/>
|
|
)}
|
|
|
|
{gardenOpen && (
|
|
<GardenPanel
|
|
onClose={() => setGardenOpen(false)}
|
|
onOpenDoc={(id) => {
|
|
setGardenOpen(false)
|
|
void openDoc(id)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{updateAvailable && <UpdateBanner />}
|
|
|
|
{/* The session lapsed. The editor stays visible behind this — nothing has
|
|
been taken away — and anything unsaved is already on disk. */}
|
|
{signedOut && <SignInOverlay hasDraft={status === 'signed-out'} />}
|
|
|
|
<div className="petal-no-print">
|
|
<PetalCompanion
|
|
wordCount={wordCount}
|
|
saveStatus={status}
|
|
llmDown={llmDown}
|
|
editTick={editTick}
|
|
acceptTick={acceptTick}
|
|
text={docText}
|
|
blankPage={wordCount === 0 && docText.trim() === ''}
|
|
onAcceptInvitation={handleAcceptInvitation}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|