James Brunton 6d3b08d9b6
Enable ESLint no-empty rule (#4342)
# Description of Changes
Enable ESLint [no-empty
rule](https://eslint.org/docs/latest/rules/no-empty)
2025-09-04 15:59:31 +01:00

103 lines
3.7 KiB
TypeScript

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]);
}