diff --git a/docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg b/docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg index fc81faa9c..c5d424a66 100644 Binary files a/docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg and b/docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-en-light.jpg b/docs/screenshots/menu-layout/after-gen-filled-en-light.jpg index 61c873576..5f6d0455b 100644 Binary files a/docs/screenshots/menu-layout/after-gen-filled-en-light.jpg and b/docs/screenshots/menu-layout/after-gen-filled-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-es-light.jpg b/docs/screenshots/menu-layout/after-gen-filled-es-light.jpg index 2352576c6..4d2a366bf 100644 Binary files a/docs/screenshots/menu-layout/after-gen-filled-es-light.jpg and b/docs/screenshots/menu-layout/after-gen-filled-es-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg b/docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg index f380f2bcd..8460b4d58 100644 Binary files a/docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg and b/docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg b/docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg index 4ad977e3e..64c3b82f2 100644 Binary files a/docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg and b/docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg b/docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg index 8f7ab6fd0..1622abd74 100644 Binary files a/docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg and b/docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-saved-en-dark.jpg b/docs/screenshots/menu-layout/after-gen-saved-en-dark.jpg new file mode 100644 index 000000000..c12c35dbc Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-saved-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-saved-en-light.jpg b/docs/screenshots/menu-layout/after-gen-saved-en-light.jpg new file mode 100644 index 000000000..8b7a7a23a Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-saved-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-saved-hu-dark.jpg b/docs/screenshots/menu-layout/after-gen-saved-hu-dark.jpg new file mode 100644 index 000000000..bb1d2ce31 Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-saved-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg b/docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg index f8fae342e..d009cab7d 100644 Binary files a/docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg and b/docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-share-en-light.jpg b/docs/screenshots/menu-layout/after-share-en-light.jpg new file mode 100644 index 000000000..e79e27bc2 Binary files /dev/null and b/docs/screenshots/menu-layout/after-share-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-share-hu-dark.jpg b/docs/screenshots/menu-layout/after-share-hu-dark.jpg new file mode 100644 index 000000000..db75c4f29 Binary files /dev/null and b/docs/screenshots/menu-layout/after-share-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg b/docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg index 7e1f62ae1..ae14a902e 100644 Binary files a/docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg and b/docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-en-light.jpg b/docs/screenshots/menu-layout/before-gen-filled-en-light.jpg index 864c9cb68..dfe234358 100644 Binary files a/docs/screenshots/menu-layout/before-gen-filled-en-light.jpg and b/docs/screenshots/menu-layout/before-gen-filled-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-es-light.jpg b/docs/screenshots/menu-layout/before-gen-filled-es-light.jpg index 049d8c54f..d8bfb1306 100644 Binary files a/docs/screenshots/menu-layout/before-gen-filled-es-light.jpg and b/docs/screenshots/menu-layout/before-gen-filled-es-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg b/docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg index d85ac942d..9b50e4c98 100644 Binary files a/docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg and b/docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg b/docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg index e4070f66e..bed3f81de 100644 Binary files a/docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg and b/docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-regenerating-en-light.jpg b/docs/screenshots/menu-layout/before-gen-regenerating-en-light.jpg index 6e541146c..a9457a22d 100644 Binary files a/docs/screenshots/menu-layout/before-gen-regenerating-en-light.jpg and b/docs/screenshots/menu-layout/before-gen-regenerating-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-saved-en-dark.jpg b/docs/screenshots/menu-layout/before-gen-saved-en-dark.jpg new file mode 100644 index 000000000..5ab6b3051 Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-saved-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-saved-en-light.jpg b/docs/screenshots/menu-layout/before-gen-saved-en-light.jpg new file mode 100644 index 000000000..d9d8bc96a Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-saved-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-saved-hu-dark.jpg b/docs/screenshots/menu-layout/before-gen-saved-hu-dark.jpg new file mode 100644 index 000000000..23733eefc Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-saved-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg b/docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg index 8f43ba248..fb170838a 100644 Binary files a/docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg and b/docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-share-en-light.jpg b/docs/screenshots/menu-layout/before-share-en-light.jpg new file mode 100644 index 000000000..de910d818 Binary files /dev/null and b/docs/screenshots/menu-layout/before-share-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-share-hu-dark.jpg b/docs/screenshots/menu-layout/before-share-hu-dark.jpg new file mode 100644 index 000000000..0c3b4ac4d Binary files /dev/null and b/docs/screenshots/menu-layout/before-share-hu-dark.jpg differ diff --git a/src/components/features/ExportToolbar.tsx b/src/components/features/ExportToolbar.tsx index 167599956..9b6139333 100644 --- a/src/components/features/ExportToolbar.tsx +++ b/src/components/features/ExportToolbar.tsx @@ -1,4 +1,4 @@ -import { Children, type ReactNode } from 'react'; +import { Children, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { useTranslation } from 'react-i18next'; import { FileText, FileDown, MessageSquare } from 'lucide-react'; @@ -17,7 +17,10 @@ interface ExportToolbarProps { extraExports?: ReactNode; /** Buttons that appear on the top row (Save, Discuss, Flowcharts, Undo, etc.). */ secondaryActions?: ReactNode; - /** On a phone, both rows stretch their buttons into even pairs and the EXPORT tag takes its own line. */ + /** + * Rows that wrap fill evenly. On a phone both rows stretch into pairs and the EXPORT tag takes its + * own line; wider, a row that needs two lines splits its buttons evenly (3+2, 3+3) and stretches them. + */ evenRows?: boolean; } @@ -25,7 +28,77 @@ interface ExportToolbarProps { // label too long for half takes the whole line; nothing is left ragged. Buttons // set their own touch min-width, which would let a long label shrink into half // a row, so the width of the label wins. -const EVEN_ROW = 'max-sm:[&>*]:grow max-sm:[&>*]:basis-[calc(50%-0.25rem)] max-sm:[&>*]:whitespace-nowrap max-sm:[&>*]:min-w-fit!'; +const EVEN_ROW = 'max-sm:[&>*]:grow max-sm:[&>*]:basis-[calc(50%-0.25rem)] [&>*]:whitespace-nowrap [&>*]:min-w-fit!'; + +const WIDE = '(min-width: 40rem)'; + +type Split = { lines: number[][]; spread: number; widest: number }; + +// Every way to cut the items into consecutive lines that each fit. The best split uses the fewest +// lines, then keeps their counts closest (3+2 over 4+1), then the narrowest widest line. +function balancedSplit(widths: number[], room: number, gap: number): number[][] { + let best: Split | null = null; + const cuts = 1 << (widths.length - 1); + for (let mask = 0; mask < cuts; mask++) { + const lines: number[][] = [[0]]; + for (let i = 1; i < widths.length; i++) { + if (mask & (1 << (i - 1))) lines.push([i]); + else lines[lines.length - 1].push(i); + } + const lengths = lines.map((line) => line.reduce((sum, i) => sum + widths[i], 0) + gap * (line.length - 1)); + if (lengths.some((length) => length > room)) continue; + const counts = lines.map((line) => line.length); + const split = { lines, spread: Math.max(...counts) - Math.min(...counts), widest: Math.max(...lengths) }; + if (!best || split.lines.length < best.lines.length + || (split.lines.length === best.lines.length && (split.spread < best.spread + || (split.spread === best.spread && split.widest < best.widest)))) best = split; + } + return best?.lines ?? widths.map((_, i) => [i]); +} + +// From 640px up, a row that fits stays one natural line. One that wraps is cut into balanced lines, +// and each line's items grow in proportion to their labels until the line is full. An item marked +// data-row-fixed (the EXPORT tag) keeps its own width. Phones keep the CSS pairs above. +function useBalancedRow(ref: RefObject, enabled: boolean) { + useLayoutEffect(() => { + const row = ref.current; + if (!enabled || !row || typeof ResizeObserver === 'undefined') return; + const items = () => Array.from(row.children) as HTMLElement[]; + const reset = () => items().forEach((item) => { item.style.flexBasis = ''; item.style.flexGrow = ''; }); + const wide = window.matchMedia(WIDE); + let live = true; + const layout = () => { + if (!live) return; + reset(); + const room = row.clientWidth; + const all = items(); + if (!wide.matches || room === 0 || all.length < 2) return; + const gap = parseFloat(getComputedStyle(row).columnGap) || 0; + const widths = all.map((item) => item.getBoundingClientRect().width); + const lines = balancedSplit(widths, room, gap); + if (lines.length < 2) return; + for (const line of lines) { + const fixed = line.filter((i) => all[i].dataset.rowFixed !== undefined); + const fixedWidth = fixed.reduce((sum, i) => sum + widths[i], 0); + const growing = line.filter((i) => !fixed.includes(i)); + const labels = growing.reduce((sum, i) => sum + widths[i], 0); + const free = room - gap * (line.length - 1) - fixedWidth; + for (const i of line) { + // Half a pixel short of full, so rounding never pushes the next line's first item up. + const basis = fixed.includes(i) ? widths[i] : (free * widths[i]) / labels - 0.5; + all[i].style.flexBasis = `${basis}px`; + all[i].style.flexGrow = fixed.includes(i) ? '0' : '1'; + } + } + }; + layout(); + const observer = new ResizeObserver(layout); + observer.observe(row); + wide.addEventListener('change', layout); + void document.fonts?.ready.then(layout); + return () => { live = false; observer.disconnect(); wide.removeEventListener('change', layout); reset(); }; + }); +} // Unified export bar used by every feature's results view. // Two explicit rows, both left-aligned: @@ -44,6 +117,10 @@ export function ExportToolbar({ evenRows = false, }: ExportToolbarProps) { const { t } = useTranslation(); + const topRow = useRef(null); + const exportRow = useRef(null); + useBalancedRow(topRow, evenRows); + useBalancedRow(exportRow, evenRows); const hasSecondaryActions = Children.toArray(secondaryActions).filter(Boolean).length > 0; const hasTopRow = hasSecondaryActions || !!onDiscuss; @@ -56,7 +133,7 @@ export function ExportToolbar({ return (
{hasTopRow && ( -
+
{secondaryActions} {onDiscuss && (
)} -
- {evenRows ?
{exportTag}
: exportTag} +
+ {evenRows ?
{exportTag}
: exportTag} {extraExports}