diff --git a/frontend/src/utils/FitText.tsx b/frontend/src/components/shared/FitText.tsx similarity index 62% rename from frontend/src/utils/FitText.tsx rename to frontend/src/components/shared/FitText.tsx index 485c08cb5..4951af42e 100644 --- a/frontend/src/utils/FitText.tsx +++ b/frontend/src/components/shared/FitText.tsx @@ -1,5 +1,5 @@ import React, { CSSProperties, useMemo, useRef } from 'react'; -import { useAdjustFontSizeToFit } from './textFit'; +import { useAdjustFontSizeToFit } from './fitText/textFit'; type FitTextProps = { text: string; @@ -9,6 +9,11 @@ type FitTextProps = { className?: string; style?: CSSProperties; as?: 'span' | 'div'; + /** + * Insert zero-width soft breaks after these characters to prefer wrapping at them + * when multi-line is enabled. Defaults to '/'. Ignored when lines === 1. + */ + softBreakChars?: string | string[]; }; const FitText: React.FC = ({ @@ -19,6 +24,7 @@ const FitText: React.FC = ({ className, style, as = 'span', + softBreakChars = '/', }) => { const ref = useRef(null); @@ -34,6 +40,16 @@ const FitText: React.FC = ({ // React doesn't create a new component function on each render. const ElementTag: any = useMemo(() => as, [as]); + // For the / character, insert zero-width soft breaks to prefer wrapping at them + const displayText = useMemo(() => { + if (!text) return text; + if (!lines || lines <= 1) return text; + const chars = Array.isArray(softBreakChars) ? softBreakChars : [softBreakChars]; + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`(${chars.filter(Boolean).map(esc).join('|')})`, 'g'); + return text.replace(re, `$1\u200B`); + }, [text, lines, softBreakChars]); + const clampStyles: CSSProperties = { // Multi-line clamp with ellipsis fallback whiteSpace: lines === 1 ? 'nowrap' : 'normal', @@ -43,14 +59,14 @@ const FitText: React.FC = ({ WebkitBoxOrient: lines > 1 ? ('vertical' as any) : undefined, WebkitLineClamp: lines > 1 ? (lines as any) : undefined, lineClamp: lines > 1 ? (lines as any) : undefined, - wordBreak: 'break-word', - overflowWrap: 'anywhere', + wordBreak: 'normal', + overflowWrap: lines === 1 ? ('normal' as any) : ('break-word' as any), fontSize: fontSize ? `${fontSize}px` : undefined, }; return ( - {text} + {displayText} ); }; diff --git a/frontend/src/components/shared/fitText/FitText.README.md b/frontend/src/components/shared/fitText/FitText.README.md new file mode 100644 index 000000000..8b28a1b03 --- /dev/null +++ b/frontend/src/components/shared/fitText/FitText.README.md @@ -0,0 +1,111 @@ +# FitText Component + +Adaptive text component that automatically scales font size down so the content fits within its container, with optional multi-line clamping. Built with a small hook wrapper around ResizeObserver and MutationObserver for reliable, responsive fitting. + +## Features + +- ๐Ÿ“ Auto-fit text to available width (and optional line count) +- ๐Ÿงต Single-line and multi-line support with clamping and ellipsis +- ๐Ÿ” React hook + component interface +- โšก Efficient: observers and rAF, minimal layout thrash +- ๐ŸŽ›๏ธ Configurable min scale, max font size, and step size + +## Behavior + +- On mount and whenever size/text changes, the font is reduced (never increased) until the text fits the given constraints. +- If `lines` is provided, height is constrained to an estimated maximum based on computed line-height. + +## Basic Usage + +```tsx +import FitText from '@/components/shared/FitText'; + +export function CardTitle({ title }: { title: string }) { + return ( + + ); +} +``` + +## API Reference + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `text` | `string` | โ€” | The string to render and fit | +| `fontSize` | `number` | computed | Maximum starting font size in px | +| `minimumFontScale` | `number` | `0.8` | Smallest scale relative to the max (0..1) | +| `lines` | `number` | `1` | Maximum number of lines to display and fit | +| `className` | `string` | โ€” | Optional class on the rendered element | +| `style` | `CSSProperties` | โ€” | Inline styles (merged with internal clamp styles) | +| `as` | `'span' | 'div'` | `'span'` | HTML tag to render | + +Notes: +- For multi-line, the component applies WebKit line clamping (with reasonable fallbacks) and fits within that height. +- The component only scales down; if the content already fits, it keeps the starting size. + +## Examples + +### Single-line title (default) + +```tsx + +``` + +### Multi-line label (up to 3 lines) + +```tsx + +``` + +### Explicit starting size + +```tsx + +``` + +### Render as a div + +```tsx + +``` + +## Hook Usage (Advanced) + +If you need to control your own element, you can use the underlying hook directly. + +```tsx +import React, { useRef } from 'react'; +import { useAdjustFontSizeToFit } from '@/components/shared/fitText/textFit'; + +export function CustomFit() { + const ref = useRef(null); + + useAdjustFontSizeToFit(ref as any, { + maxFontSizePx: 20, + minFontScale: 0.6, + maxLines: 2, + singleLine: false, + }); + + return ( + + Arbitrary text that will scale to fit two lines. + + ); +} +``` + +## Tips + +- For predictable measurements, ensure the container has a fixed width (or stable layout) when fitting occurs. +- Avoid animating width while fitting; update after animation completes for best results. +- When you need more control of typography, pass `fontSize` to define the starting ceiling. + + diff --git a/frontend/src/utils/textFit.ts b/frontend/src/components/shared/fitText/textFit.ts similarity index 100% rename from frontend/src/utils/textFit.ts rename to frontend/src/components/shared/fitText/textFit.ts diff --git a/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css index c14ea9b1f..2147f8bc4 100644 --- a/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css +++ b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css @@ -209,7 +209,7 @@ } .current-tool-slot.visible { - max-height: 96px; /* icon + label + divider */ + max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */ opacity: 1; } @@ -224,7 +224,7 @@ opacity: 0; } 100% { - max-height: 90px; /* enough space for icon + label */ + max-height: 7.875rem; /* enough space for icon + up to 3-line label (126px) */ opacity: 1; } } diff --git a/frontend/src/components/shared/quickAccessBar/TopToolIndicator.tsx b/frontend/src/components/shared/quickAccessBar/TopToolIndicator.tsx index 06a585d9c..bae269303 100644 --- a/frontend/src/components/shared/quickAccessBar/TopToolIndicator.tsx +++ b/frontend/src/components/shared/quickAccessBar/TopToolIndicator.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ActionIcon, Divider } from '@mantine/core'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext'; -import FitText from '../../../utils/FitText'; +import FitText from '../FitText'; import { Tooltip } from '../Tooltip'; interface TopToolIndicatorProps { @@ -99,7 +99,7 @@ const TopToolIndicator: React.FC = ({ activeButton, setAc diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 7319fbf0d..b5d93bdad 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Button } from "@mantine/core"; import { Tooltip } from "../../shared/Tooltip"; import { type ToolRegistryEntry } from "../../../data/toolRegistry"; +import FitText from "../../shared/FitText"; interface ToolButtonProps { id: string; @@ -24,7 +25,13 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect className="tool-button" styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }} > - {tool.name} + );