diff --git a/web/src/App.tsx b/web/src/App.tsx index 7a8dad2..ff2b504 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() {
-
+ -
+ -
+ {/* 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. */} +
{t.docs.language}
{packs.map((p) => { diff --git a/web/src/components/Editor/ChromeStrip.tsx b/web/src/components/Editor/ChromeStrip.tsx new file mode 100644 index 0000000..b1258dd --- /dev/null +++ b/web/src/components/Editor/ChromeStrip.tsx @@ -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(null) + const [edge, setEdge] = useState('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 ( +
+ {children} +
+ ) +} diff --git a/web/src/components/Editor/ToneSelect.tsx b/web/src/components/Editor/ToneSelect.tsx index 2036fe7..b7c7645 100644 --- a/web/src/components/Editor/ToneSelect.tsx +++ b/web/src/components/Editor/ToneSelect.tsx @@ -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(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 ( -
+