mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-22 04:09:22 +00:00
add a fit text component that acts the same as the react native adjustFontSizeToFit prop
This commit is contained in:
parent
b3013647b9
commit
4de65c1cc0
@ -1,5 +1,5 @@
|
|||||||
import React, { CSSProperties, useMemo, useRef } from 'react';
|
import React, { CSSProperties, useMemo, useRef } from 'react';
|
||||||
import { useAdjustFontSizeToFit } from './textFit';
|
import { useAdjustFontSizeToFit } from './fitText/textFit';
|
||||||
|
|
||||||
type FitTextProps = {
|
type FitTextProps = {
|
||||||
text: string;
|
text: string;
|
||||||
@ -9,6 +9,11 @@ type FitTextProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
as?: 'span' | 'div';
|
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<FitTextProps> = ({
|
const FitText: React.FC<FitTextProps> = ({
|
||||||
@ -19,6 +24,7 @@ const FitText: React.FC<FitTextProps> = ({
|
|||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
as = 'span',
|
as = 'span',
|
||||||
|
softBreakChars = '/',
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLElement | null>(null);
|
const ref = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
@ -34,6 +40,16 @@ const FitText: React.FC<FitTextProps> = ({
|
|||||||
// React doesn't create a new component function on each render.
|
// React doesn't create a new component function on each render.
|
||||||
const ElementTag: any = useMemo(() => as, [as]);
|
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 = {
|
const clampStyles: CSSProperties = {
|
||||||
// Multi-line clamp with ellipsis fallback
|
// Multi-line clamp with ellipsis fallback
|
||||||
whiteSpace: lines === 1 ? 'nowrap' : 'normal',
|
whiteSpace: lines === 1 ? 'nowrap' : 'normal',
|
||||||
@ -43,14 +59,14 @@ const FitText: React.FC<FitTextProps> = ({
|
|||||||
WebkitBoxOrient: lines > 1 ? ('vertical' as any) : undefined,
|
WebkitBoxOrient: lines > 1 ? ('vertical' as any) : undefined,
|
||||||
WebkitLineClamp: lines > 1 ? (lines as any) : undefined,
|
WebkitLineClamp: lines > 1 ? (lines as any) : undefined,
|
||||||
lineClamp: lines > 1 ? (lines as any) : undefined,
|
lineClamp: lines > 1 ? (lines as any) : undefined,
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'normal',
|
||||||
overflowWrap: 'anywhere',
|
overflowWrap: lines === 1 ? ('normal' as any) : ('break-word' as any),
|
||||||
fontSize: fontSize ? `${fontSize}px` : undefined,
|
fontSize: fontSize ? `${fontSize}px` : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ElementTag ref={ref} className={className} style={{ ...clampStyles, ...style }}>
|
<ElementTag ref={ref} className={className} style={{ ...clampStyles, ...style }}>
|
||||||
{text}
|
{displayText}
|
||||||
</ElementTag>
|
</ElementTag>
|
||||||
);
|
);
|
||||||
};
|
};
|
111
frontend/src/components/shared/fitText/FitText.README.md
Normal file
111
frontend/src/components/shared/fitText/FitText.README.md
Normal file
@ -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 (
|
||||||
|
<FitText text={title} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<FitText text="Very long single-line title that should shrink" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-line label (up to 3 lines)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FitText
|
||||||
|
text="This label can wrap up to three lines and will shrink so it fits nicely"
|
||||||
|
lines={3}
|
||||||
|
minimumFontScale={0.6}
|
||||||
|
className="my-multiline-label"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Explicit starting size
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FitText text="Starts at 18px, scales down if needed" fontSize={18} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Render as a div
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FitText as="div" text="Block-level content" lines={2} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
useAdjustFontSizeToFit(ref as any, {
|
||||||
|
maxFontSizePx: 20,
|
||||||
|
minFontScale: 0.6,
|
||||||
|
maxLines: 2,
|
||||||
|
singleLine: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} style={{ display: 'inline-block', maxWidth: 240 }}>
|
||||||
|
Arbitrary text that will scale to fit two lines.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
|
@ -209,7 +209,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current-tool-slot.visible {
|
.current-tool-slot.visible {
|
||||||
max-height: 96px; /* icon + label + divider */
|
max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +224,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
100% {
|
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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { ActionIcon, Divider } from '@mantine/core';
|
import { ActionIcon, Divider } from '@mantine/core';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
|
||||||
import FitText from '../../../utils/FitText';
|
import FitText from '../FitText';
|
||||||
import { Tooltip } from '../Tooltip';
|
import { Tooltip } from '../Tooltip';
|
||||||
|
|
||||||
interface TopToolIndicatorProps {
|
interface TopToolIndicatorProps {
|
||||||
@ -99,7 +99,7 @@ const TopToolIndicator: React.FC<TopToolIndicatorProps> = ({ activeButton, setAc
|
|||||||
<FitText
|
<FitText
|
||||||
as="span"
|
as="span"
|
||||||
text={indicatorTool.name}
|
text={indicatorTool.name}
|
||||||
lines={2}
|
lines={3}
|
||||||
minimumFontScale={0.4}
|
minimumFontScale={0.4}
|
||||||
className="button-text active current-tool-label"
|
className="button-text active current-tool-label"
|
||||||
/>
|
/>
|
||||||
|
@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { Tooltip } from "../../shared/Tooltip";
|
import { Tooltip } from "../../shared/Tooltip";
|
||||||
import { type ToolRegistryEntry } from "../../../data/toolRegistry";
|
import { type ToolRegistryEntry } from "../../../data/toolRegistry";
|
||||||
|
import FitText from "../../shared/FitText";
|
||||||
|
|
||||||
interface ToolButtonProps {
|
interface ToolButtonProps {
|
||||||
id: string;
|
id: string;
|
||||||
@ -24,7 +25,13 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
|
|||||||
className="tool-button"
|
className="tool-button"
|
||||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
||||||
>
|
>
|
||||||
{tool.name}
|
<FitText
|
||||||
|
text={tool.name}
|
||||||
|
lines={1}
|
||||||
|
minimumFontScale={0.6}
|
||||||
|
as="span"
|
||||||
|
style={{ display: 'inline-block', maxWidth: '100%' }}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user