Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/after-gen-filled-en-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/after-gen-filled-es-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/before-gen-filled-en-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/before-gen-filled-es-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 83 additions & 6 deletions src/components/features/ExportToolbar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,15 +17,88 @@ 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;
}

// On a phone each button grows from half the row, so two share a line and a
// 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<HTMLDivElement>, 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:
Expand All @@ -44,6 +117,10 @@ export function ExportToolbar({
evenRows = false,
}: ExportToolbarProps) {
const { t } = useTranslation();
const topRow = useRef<HTMLDivElement>(null);
const exportRow = useRef<HTMLDivElement>(null);
useBalancedRow(topRow, evenRows);
useBalancedRow(exportRow, evenRows);

const hasSecondaryActions = Children.toArray(secondaryActions).filter(Boolean).length > 0;
const hasTopRow = hasSecondaryActions || !!onDiscuss;
Expand All @@ -56,7 +133,7 @@ export function ExportToolbar({
return (
<div className="flex w-full flex-col items-start gap-2">
{hasTopRow && (
<div className={cn('flex w-full flex-wrap items-center justify-start gap-2', evenRows && EVEN_ROW)}>
<div ref={topRow} className={cn('flex w-full flex-wrap items-center justify-start gap-2', evenRows && EVEN_ROW)}>
{secondaryActions}
{onDiscuss && (
<Button variant="outline" size="sm" className="whitespace-nowrap" onClick={onDiscuss}>
Expand All @@ -66,8 +143,8 @@ export function ExportToolbar({
)}
</div>
)}
<div className={cn('flex w-full flex-wrap items-center justify-start gap-2', evenRows && EVEN_ROW)}>
{evenRows ? <div className="flex max-sm:basis-full!">{exportTag}</div> : exportTag}
<div ref={exportRow} className={cn('flex w-full flex-wrap items-center justify-start gap-2', evenRows && EVEN_ROW)}>
{evenRows ? <div data-row-fixed className="flex max-sm:basis-full!">{exportTag}</div> : exportTag}
{extraExports}
<Button variant="outline" size="sm" className="whitespace-nowrap" onClick={onPdf}>
<FileText className="mr-2 h-4 w-4" />
Expand Down
Loading