Compare commits
2
Commits
3e714b6f00
...
9c40a8ad3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c40a8ad3f | ||
|
|
ac1c6cddb0 |
+39
-25
@@ -7,6 +7,7 @@ import { useTags } from './hooks/useTags'
|
|||||||
import { DocList } from './components/DocList/DocList'
|
import { DocList } from './components/DocList/DocList'
|
||||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||||
import { ToneSelect } from './components/Editor/ToneSelect'
|
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||||
|
import { ChromeStrip } from './components/Editor/ChromeStrip'
|
||||||
import { ExportMenu } from './components/Export/ExportMenu'
|
import { ExportMenu } from './components/Export/ExportMenu'
|
||||||
import { HistoryPanel } from './components/History/HistoryPanel'
|
import { HistoryPanel } from './components/History/HistoryPanel'
|
||||||
import { GardenPanel } from './components/Garden/GardenPanel'
|
import { GardenPanel } from './components/Garden/GardenPanel'
|
||||||
@@ -435,7 +436,7 @@ export default function App() {
|
|||||||
<PetalFall night={night} />
|
<PetalFall night={night} />
|
||||||
<header
|
<header
|
||||||
onMouseDown={handleChromeDown}
|
onMouseDown={handleChromeDown}
|
||||||
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
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)' }}
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -451,7 +452,15 @@ export default function App() {
|
|||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xl">🌸</span>
|
<span className="text-xl">🌸</span>
|
||||||
<span className="text-lg font-extrabold text-plum">Petal</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setGardenOpen(true)}
|
onClick={() => setGardenOpen(true)}
|
||||||
@@ -503,38 +512,43 @@ export default function App() {
|
|||||||
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
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">
|
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
||||||
<div className="mb-5 flex items-center gap-3">
|
{/* 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
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => handleTitleChange(e.target.value)}
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
placeholder="Untitled"
|
placeholder="Untitled"
|
||||||
aria-label="Document title"
|
aria-label="Document title"
|
||||||
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
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)' }}
|
style={{ fontFamily: 'var(--font-ui)' }}
|
||||||
/>
|
/>
|
||||||
<div className="petal-no-print shrink-0">
|
<ChromeStrip className="petal-no-print flex items-center gap-2 py-0.5 md:gap-3">
|
||||||
<ToneSelect value={tone} onChange={handleToneChange} />
|
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||||
</div>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setHistoryOpen(true)}
|
||||||
onClick={() => setHistoryOpen(true)}
|
aria-label="Version history"
|
||||||
aria-label="Version history"
|
title="Browse and restore earlier versions"
|
||||||
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"
|
||||||
className="petal-no-print inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
style={{
|
||||||
style={{
|
borderRadius: 'var(--radius-pill)',
|
||||||
borderRadius: 'var(--radius-pill)',
|
background: 'var(--color-surface)',
|
||||||
background: 'var(--color-surface)',
|
color: 'var(--color-plum)',
|
||||||
color: 'var(--color-plum)',
|
boxShadow: 'var(--shadow-soft)',
|
||||||
boxShadow: 'var(--shadow-soft)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<span aria-hidden>🕘</span>
|
||||||
<span aria-hidden>🕘</span>
|
<span>{t.app.history}</span>
|
||||||
<span>{t.app.history}</span>
|
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
||||||
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
</button>
|
||||||
</button>
|
|
||||||
<div className="petal-no-print">
|
|
||||||
<ExportMenu docId={currentDoc.id} />
|
<ExportMenu docId={currentDoc.id} />
|
||||||
</div>
|
</ChromeStrip>
|
||||||
</div>
|
</div>
|
||||||
<EditorCore
|
<EditorCore
|
||||||
key={`${currentDoc.id}:${editorEpoch}`}
|
key={`${currentDoc.id}:${editorEpoch}`}
|
||||||
|
|||||||
@@ -47,7 +47,15 @@ export function LanguagePicker() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 px-1">
|
<div className="flex flex-col gap-1 px-1">
|
||||||
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
|
{/* Label and buttons wrap as a pair: the label is itself bilingual
|
||||||
|
("Langue · Language"), and three self-naming buttons beside it need
|
||||||
|
more than the drawer is wide in every language Petal ships. When they
|
||||||
|
don't fit the buttons drop to their own line instead of spilling out
|
||||||
|
of the drawer. */}
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
<span className="shrink-0 font-semibold">{t.docs.language}</span>
|
<span className="shrink-0 font-semibold">{t.docs.language}</span>
|
||||||
<div className="ml-auto flex shrink-0 gap-1">
|
<div className="ml-auto flex shrink-0 gap-1">
|
||||||
{packs.map((p) => {
|
{packs.map((p) => {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// ChromeStrip is the row of document pills (tone, history, export) on a screen
|
||||||
|
// too narrow to hold them. It scrolls within itself rather than letting the
|
||||||
|
// overflow escape into the editor pane, and it says which way the rest of the
|
||||||
|
// row is.
|
||||||
|
//
|
||||||
|
// Saying so matters more here than in most scrollers: the pills are the only
|
||||||
|
// way to reach three features, and a pill that has scrolled out of sight is
|
||||||
|
// indistinguishable from a pill that doesn't exist. So each edge with more
|
||||||
|
// behind it fades — the same "there is more this way" language .petal-toolbar
|
||||||
|
// speaks with its clipped right edge, except this row can be scrolled from
|
||||||
|
// either end and has to point in the right direction.
|
||||||
|
//
|
||||||
|
// The fade is a mask rather than a gradient overlay so it works on whatever is
|
||||||
|
// behind it (the cream page, the night theme, a falling petal) without knowing
|
||||||
|
// the background colour.
|
||||||
|
type Edge = 'none' | 'left' | 'right' | 'both'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChromeStrip({ className = '', children }: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const [edge, setEdge] = useState<Edge>('none')
|
||||||
|
|
||||||
|
// A pixel of slack: scrollLeft is fractional under browser zoom and on
|
||||||
|
// high-DPI screens, so an exactly-scrolled-to-the-end strip can report
|
||||||
|
// something like 0.5px remaining and fade an edge that has nothing behind it.
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const more = el.scrollWidth - el.clientWidth - el.scrollLeft > 1
|
||||||
|
const less = el.scrollLeft > 1
|
||||||
|
setEdge(less && more ? 'both' : less ? 'left' : more ? 'right' : 'none')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
measure()
|
||||||
|
el.addEventListener('scroll', measure, { passive: true })
|
||||||
|
// Both halves of "does it fit" can change without a scroll: the window
|
||||||
|
// resizes, or the labels themselves change when she switches her pair
|
||||||
|
// language and every pill in the row grows or shrinks at once.
|
||||||
|
const ro = new ResizeObserver(measure)
|
||||||
|
ro.observe(el)
|
||||||
|
for (const child of Array.from(el.children)) ro.observe(child)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', measure)
|
||||||
|
ro.disconnect()
|
||||||
|
}
|
||||||
|
}, [measure])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-edge={edge} className={`petal-chrome-strip ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
|
import { useAnchoredMenu } from './anchoredMenu'
|
||||||
|
|
||||||
// ToneSelect lets the writer set the document's target tone, which steers the
|
// 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
|
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
|
||||||
@@ -35,6 +36,7 @@ export function ToneSelect({ value, onChange }: Props) {
|
|||||||
const pk = usePack()
|
const pk = usePack()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 200)
|
||||||
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||||
|
|
||||||
// Click outside closes the menu.
|
// Click outside closes the menu.
|
||||||
@@ -48,8 +50,9 @@ export function ToneSelect({ value, onChange }: Props) {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative shrink-0">
|
<div ref={ref} className="shrink-0">
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Document tone"
|
aria-label="Document tone"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
@@ -75,9 +78,9 @@ export function ToneSelect({ value, onChange }: Props) {
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
className="petal-word-card p-1.5"
|
||||||
style={{
|
style={{
|
||||||
width: 200,
|
...menuStyle,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-card)',
|
borderRadius: 'var(--radius-card)',
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||||
|
|
||||||
|
// A dropdown that hangs off its own button in viewport coordinates.
|
||||||
|
//
|
||||||
|
// The tone and export menus used to be absolutely positioned inside their
|
||||||
|
// button's wrapper, which was fine until that wrapper became ChromeStrip — a
|
||||||
|
// horizontal scroller, and so a box that clips what overflows it. An absolute
|
||||||
|
// menu inside it is 36px tall and scrolls away with the pills. Positioning the
|
||||||
|
// menu against the viewport instead takes it out of the strip's hands entirely:
|
||||||
|
// nothing clips a fixed box unless an ancestor has a transform, and none of the
|
||||||
|
// editor's chrome does.
|
||||||
|
//
|
||||||
|
// The trade is that a fixed box doesn't follow its anchor, so anything that
|
||||||
|
// moves the button — the page scrolling under it, the strip scrolling, the
|
||||||
|
// window resizing — has to re-place the menu.
|
||||||
|
export function useAnchoredMenu(open: boolean, width: number) {
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
// Nothing to place before the first measurement; keeping it off-screen rather
|
||||||
|
// than at 0,0 means no flash in the top-left corner on open.
|
||||||
|
const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', top: -9999, left: -9999 })
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const place = () => {
|
||||||
|
const el = triggerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
// Right-aligned under the button, as it always was — but pulled back
|
||||||
|
// inside the window if that would hang the menu off a narrow screen.
|
||||||
|
const left = Math.max(8, Math.min(r.right - width, window.innerWidth - width - 8))
|
||||||
|
setStyle({ position: 'fixed', top: r.bottom + 6, left, width, zIndex: 40 })
|
||||||
|
}
|
||||||
|
place()
|
||||||
|
window.addEventListener('resize', place)
|
||||||
|
// Capture, so this hears the editor pane and the pill strip scrolling —
|
||||||
|
// neither of which bubbles a scroll event to the window.
|
||||||
|
window.addEventListener('scroll', place, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', place)
|
||||||
|
window.removeEventListener('scroll', place, true)
|
||||||
|
}
|
||||||
|
}, [open, width])
|
||||||
|
|
||||||
|
return { triggerRef, style }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api, type ExportFormat } from '../../api/client'
|
import { api, type ExportFormat } from '../../api/client'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
|
import { useAnchoredMenu } from '../Editor/anchoredMenu'
|
||||||
|
|
||||||
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
|
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
|
||||||
// plain <a download> links to the server's export endpoint (which sets the
|
// plain <a download> links to the server's export endpoint (which sets the
|
||||||
@@ -28,6 +29,7 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
const t = usePack()
|
const t = usePack()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 220)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -39,8 +41,9 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative shrink-0">
|
<div ref={ref} className="shrink-0">
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Export document"
|
aria-label="Export document"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
@@ -63,9 +66,9 @@ export function ExportMenu({ docId }: Props) {
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
|
className="petal-word-card p-1.5"
|
||||||
style={{
|
style={{
|
||||||
width: 220,
|
...menuStyle,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-card)',
|
borderRadius: 'var(--radius-card)',
|
||||||
|
|||||||
@@ -571,6 +571,63 @@ button, a, input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Small phones only: see the header in App.tsx for why the wordmark yields.
|
||||||
|
The threshold is low on purpose — with the header's narrow-screen padding the
|
||||||
|
longest garden label Petal ships still fits a 360px phone with its name
|
||||||
|
beside it, so only the smallest screens ever pay for the room. */
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.petal-wordmark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The document's chrome pills (tone, history, export) as a strip that scrolls
|
||||||
|
within itself. Their labels say everything twice — 历史 · History, Historique
|
||||||
|
· History — so how wide the row wants to be is a property of the langpack:
|
||||||
|
~460px in Chinese, ~550px in French, against the ~340px a phone gives it.
|
||||||
|
Something has to give, and the choice here is *not* the second half of the
|
||||||
|
label: the English is what she is learning, and a pill that quietly stops
|
||||||
|
teaching on the device she writes on most is the wrong trade.
|
||||||
|
|
||||||
|
So the row keeps every word and scrolls sideways — but only itself. Before
|
||||||
|
this, the overflow escaped to the editor pane and the whole page of writing
|
||||||
|
dragged sideways with it, which is the bug this fixes. overscroll-behavior
|
||||||
|
keeps a swipe that runs out of pills from turning into a page-level gesture,
|
||||||
|
and the scrollbar is hidden because a half-visible pill at the edge is the
|
||||||
|
affordance, the same hint .petal-toolbar gives with its faded edge. */
|
||||||
|
.petal-chrome-strip {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.petal-chrome-strip::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Which edge has more behind it. ChromeStrip sets data-edge from the scroll
|
||||||
|
position; a strip whose pills all fit is left alone entirely, so the fade
|
||||||
|
only ever appears when it means something. The mask has to clear the pills'
|
||||||
|
soft shadow, hence the fade starting a little inside the edge. */
|
||||||
|
.petal-chrome-strip[data-edge='right'] {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 88%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, #000 88%, transparent 100%);
|
||||||
|
}
|
||||||
|
.petal-chrome-strip[data-edge='left'] {
|
||||||
|
-webkit-mask-image: linear-gradient(to left, #000 88%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(to left, #000 88%, transparent 100%);
|
||||||
|
}
|
||||||
|
.petal-chrome-strip[data-edge='both'] {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0%,
|
||||||
|
#000 12%,
|
||||||
|
#000 88%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(to right, transparent 0%, #000 12%, #000 88%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
|
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
|
||||||
only the title and the writing itself reach the page. This is Petal's PDF
|
only the title and the writing itself reach the page. This is Petal's PDF
|
||||||
path — it uses the browser's own fonts, so CJK renders correctly with no
|
path — it uses the browser's own fonts, so CJK renders correctly with no
|
||||||
|
|||||||
Reference in New Issue
Block a user