The bug reads as a language-picker bug — switch off Mandarin on a phone and
the writing starts scrolling left and right — but the picker only changes
how wide Petal's own chrome wants to be. Every pill says its thing twice,
历史 · History against Historique · History, and every pill is nowrap and
shrink-0. So the title row's width is a property of the langpack: 459px in
Chinese, 551px in French, 506px in Portuguese, against the 338px column a
390px phone gives it. That row lives in the editor pane, and a pane that is
overflow-y: auto has an overflow-x of auto too, whatever the stylesheet
says. The overflow had nowhere to go but the page of writing.
Chinese was already 120px over. French is simply where it stopped being
possible to ignore.
The pills now live in a strip that scrolls itself, with overscroll-behavior
so a swipe off the end doesn't turn into a page gesture. What that buys is
the thing worth keeping: every label stays bilingual at every width. The
first version of this fix dropped the English half on phones, which fixed
the geometry by taking away the half she is learning from — on the device
she writes on most.
A scrolled pill that has left the screen is indistinguishable from a pill
that isn't there, so each edge with more behind it fades, the same hint
.petal-toolbar gives with its clipped right edge, except this row can be
scrolled from either end and has to point the right way. ChromeStrip sets
data-edge from the scroll position and re-measures when the pills resize —
which is also what catches every label changing width at once when she
switches her pair.
The tone and export menus had to leave with them. A scroll container clips
its absolutely-positioned children, so both menus would have been trapped in
a 36px-tall box; they anchor against the viewport now.
Two smaller ones the same measurement turned up:
- The header overflowed the viewport itself below 360px — a real
page-level scroll, 40px of it at 320px, off 🌷 Jardim de palavras ·
Garden. Narrower padding on phones, and under 360px the wordmark
yields. Of everything in that row it is the one thing that can go: she
is already inside the app, and the blossom stays.
- The language picker wanted 307px inside a 280px drawer and spilled out
of it. It wraps now. That one was broken in Chinese too, at 291px.
Verified in a real browser rather than by arithmetic: 310px to 780px in 10px
steps, in all three packs, no page overflow and no editor-pane overflow at
any width. Edge fades flip correctly, the export menu opens unclipped with
the strip scrolled to its end, tsc clean, 195 frontend tests pass. Desktop
is untouched — one 40px header row, as before.
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
633 lines
24 KiB
TypeScript
633 lines
24 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 { 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,
|
|
} = 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.
|
|
const handleAccept = useCallback(
|
|
async (s: Suggestion) => {
|
|
removeSuggestion(s.id)
|
|
setAcceptTick((n) => n + 1)
|
|
try {
|
|
await api.acceptSuggestion(s.id)
|
|
} catch (err) {
|
|
console.error('accept failed', err)
|
|
}
|
|
},
|
|
[removeSuggestion],
|
|
)
|
|
|
|
// 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 {
|
|
await api.dismissSuggestion(s.id)
|
|
} catch (err) {
|
|
console.error('dismiss failed', err)
|
|
}
|
|
},
|
|
[removeSuggestion],
|
|
)
|
|
|
|
// 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).
|
|
const chimedRef = useRef<Set<string>>(new Set())
|
|
useEffect(() => {
|
|
const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id))
|
|
fresh.forEach((s) => chimedRef.current.add(s.id))
|
|
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>
|
|
)
|
|
}
|