Merge fix/mobile-chrome-overflow: keep the sideways scrolling in the pills
On a phone, switching off the Mandarin pair set the whole page of writing scrolling left and right. The chrome pills say everything twice and refuse to shrink, so the title row's width is a property of the langpack, and in French it wanted 551px of a 338px column. It scrolls itself now, with a faded edge pointing at whatever is off-screen, and the bilingual labels survive at every width. Also: the header no longer overflows the viewport below 360px, and the language picker no longer spills out of the drawer. Frontend only — nothing to deploy beyond the usual rebuild. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
+39
-25
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -435,7 +436,7 @@ export default function App() {
|
||||
<PetalFall night={night} />
|
||||
<header
|
||||
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)' }}
|
||||
>
|
||||
<button
|
||||
@@ -451,7 +452,15 @@ export default function App() {
|
||||
☰
|
||||
</button>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setGardenOpen(true)}
|
||||
@@ -503,38 +512,43 @@ export default function App() {
|
||||
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 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
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Untitled"
|
||||
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)' }}
|
||||
/>
|
||||
<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} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
aria-label="Version history"
|
||||
title="Browse and restore earlier versions"
|
||||
className="petal-no-print 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>
|
||||
<div className="petal-no-print">
|
||||
<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} />
|
||||
</div>
|
||||
</ChromeStrip>
|
||||
</div>
|
||||
<EditorCore
|
||||
key={`${currentDoc.id}:${editorEpoch}`}
|
||||
|
||||
@@ -47,7 +47,15 @@ export function LanguagePicker() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="ml-auto flex shrink-0 gap-1">
|
||||
{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 { usePack } from '../../i18n'
|
||||
import { useAnchoredMenu } from './anchoredMenu'
|
||||
|
||||
// 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
|
||||
@@ -35,6 +36,7 @@ export function ToneSelect({ value, onChange }: Props) {
|
||||
const pk = usePack()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 200)
|
||||
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||
|
||||
// Click outside closes the menu.
|
||||
@@ -48,8 +50,9 @@ export function ToneSelect({ value, onChange }: Props) {
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<div ref={ref} className="shrink-0">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label="Document tone"
|
||||
aria-haspopup="listbox"
|
||||
@@ -75,9 +78,9 @@ export function ToneSelect({ value, onChange }: Props) {
|
||||
{open && (
|
||||
<div
|
||||
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={{
|
||||
width: 200,
|
||||
...menuStyle,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
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 { api, type ExportFormat } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
import { useAnchoredMenu } from '../Editor/anchoredMenu'
|
||||
|
||||
// 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
|
||||
@@ -28,6 +29,7 @@ export function ExportMenu({ docId }: Props) {
|
||||
const t = usePack()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 220)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -39,8 +41,9 @@ export function ExportMenu({ docId }: Props) {
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<div ref={ref} className="shrink-0">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label="Export document"
|
||||
aria-haspopup="menu"
|
||||
@@ -63,9 +66,9 @@ export function ExportMenu({ docId }: Props) {
|
||||
{open && (
|
||||
<div
|
||||
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={{
|
||||
width: 220,
|
||||
...menuStyle,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user