mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
Restructure frontend code to allow for extensions (#4721)
# Description of Changes Move frontend code into `core` folder and add infrastructure for `proprietary` folder to include premium, non-OSS features
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# 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 rem (e.g., 1.2, 0.9) |
|
||||
| `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 1.2rem, scales down if needed" fontSize={1.2} />
|
||||
```
|
||||
|
||||
### 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.
|
||||
- **Important**: The `fontSize` prop expects `rem` values (e.g., 1.2, 0.9) to ensure text scales with global font size changes.
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
export type AdjustFontSizeOptions = {
|
||||
/** Max font size to start from. Defaults to the element's computed font size. */
|
||||
maxFontSizePx?: number;
|
||||
/** Minimum scale relative to max size (like React Native's minimumFontScale). Default 0.7 */
|
||||
minFontScale?: number;
|
||||
/** Step as a fraction of max size used while shrinking. Default 0.05 (5%). */
|
||||
stepScale?: number;
|
||||
/** Limit the number of lines to fit. If omitted, only width is considered for multi-line. */
|
||||
maxLines?: number;
|
||||
/** If true, force single-line fitting (uses nowrap). Default false. */
|
||||
singleLine?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Imperative util: progressively reduces font-size until content fits within the element
|
||||
* (width and optional line count). Returns a cleanup that disconnects observers.
|
||||
*/
|
||||
export function adjustFontSizeToFit(
|
||||
element: HTMLElement,
|
||||
options: AdjustFontSizeOptions = {}
|
||||
): () => void {
|
||||
if (!element) return () => {};
|
||||
|
||||
const computed = window.getComputedStyle(element);
|
||||
const baseFontPx = options.maxFontSizePx ?? parseFloat(computed.fontSize || '16');
|
||||
const minScale = Math.max(0.1, options.minFontScale ?? 0.7);
|
||||
const stepScale = Math.max(0.005, options.stepScale ?? 0.05);
|
||||
const singleLine = options.singleLine ?? false;
|
||||
const maxLines = options.maxLines;
|
||||
|
||||
// Ensure measurement is consistent
|
||||
if (singleLine) {
|
||||
element.style.whiteSpace = 'nowrap';
|
||||
}
|
||||
// Never split within words; only allow natural breaks (spaces) or explicit soft breaks
|
||||
element.style.wordBreak = 'keep-all';
|
||||
element.style.overflowWrap = 'normal';
|
||||
// Disable automatic hyphenation to avoid mid-word breaks; use only manual opportunities
|
||||
element.style.setProperty('hyphens', 'manual');
|
||||
element.style.overflow = 'visible';
|
||||
|
||||
const minFontPx = baseFontPx * minScale;
|
||||
const stepPx = Math.max(0.5, baseFontPx * stepScale);
|
||||
|
||||
const fit = () => {
|
||||
// Reset to largest before measuring
|
||||
element.style.fontSize = `${baseFontPx}px`;
|
||||
|
||||
// Calculate target height threshold for line limit
|
||||
let maxHeight = Number.POSITIVE_INFINITY;
|
||||
if (typeof maxLines === 'number' && maxLines > 0) {
|
||||
const cs = window.getComputedStyle(element);
|
||||
const lineHeight = parseFloat(cs.lineHeight) || baseFontPx * 1.2;
|
||||
maxHeight = lineHeight * maxLines + 0.1; // small epsilon
|
||||
}
|
||||
|
||||
let current = baseFontPx;
|
||||
// Guard against excessive loops
|
||||
let iterations = 0;
|
||||
while (iterations < 200) {
|
||||
const fitsWidth = element.scrollWidth <= element.clientWidth + 1; // tolerance
|
||||
const fitsHeight = element.scrollHeight <= maxHeight + 1;
|
||||
const fits = fitsWidth && fitsHeight;
|
||||
if (fits || current <= minFontPx) break;
|
||||
current = Math.max(minFontPx, current - stepPx);
|
||||
element.style.fontSize = `${current}px`;
|
||||
iterations += 1;
|
||||
}
|
||||
};
|
||||
|
||||
// Defer to next frame to ensure layout is ready
|
||||
const raf = requestAnimationFrame(fit);
|
||||
|
||||
const ro = new ResizeObserver(() => fit());
|
||||
ro.observe(element);
|
||||
if (element.parentElement) ro.observe(element.parentElement);
|
||||
|
||||
const mo = new MutationObserver(() => fit());
|
||||
mo.observe(element, { characterData: true, childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
try { ro.disconnect(); } catch { /* Ignore errors */ }
|
||||
try { mo.disconnect(); } catch { /* Ignore errors */ }
|
||||
};
|
||||
}
|
||||
|
||||
/** React hook wrapper for convenience */
|
||||
export function useAdjustFontSizeToFit(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
options: AdjustFontSizeOptions = {}
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const cleanup = adjustFontSizeToFit(ref.current, options);
|
||||
return cleanup;
|
||||
}, [ref, options.maxFontSizePx, options.minFontScale, options.stepScale, options.maxLines, options.singleLine]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user