mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-22 20:29:23 +00:00
add adjustFontSizeToFit util similar to react-native, add an animated top tool icon to provide better feedback to users when switching tools
This commit is contained in:
parent
39fed225a1
commit
3c2524183c
@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
import { ActionIcon } from '@mantine/core';
|
||||||
|
import { Tooltip } from './Tooltip';
|
||||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
|
|
||||||
@ -9,7 +10,7 @@ interface AllToolsNavButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||||
const { handleReaderToggle, handleBackToTools } = useToolWorkflow();
|
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
setActiveButton('tools');
|
setActiveButton('tools');
|
||||||
@ -18,19 +19,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
|||||||
handleBackToTools();
|
handleBackToTools();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActive = activeButton === 'tools';
|
// Do not highlight All Tools when a specific tool is open (indicator is shown)
|
||||||
|
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
|
||||||
|
|
||||||
const iconNode = (
|
const iconNode = (
|
||||||
<span className="iconContainer">
|
<span className="iconContainer">
|
||||||
<AppsIcon sx={{ fontSize: '1.75rem' }} />
|
<AppsIcon sx={{ fontSize: '1.5rem' }} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={'All tools'} position="right">
|
<Tooltip content={'All tools'} sidebarTooltip>
|
||||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="lg"
|
size={'lg'}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
style={{
|
style={{
|
||||||
@ -39,6 +41,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
|
className={isActive ? 'activeIconScale' : ''}
|
||||||
>
|
>
|
||||||
{iconNode}
|
{iconNode}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, forwardRef } from "react";
|
import React, { useState, useRef, forwardRef } from "react";
|
||||||
import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core";
|
import { ActionIcon, Stack, Divider } from "@mantine/core";
|
||||||
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
|
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
|
||||||
import SettingsIcon from "@mui/icons-material/SettingsRounded";
|
import SettingsIcon from "@mui/icons-material/SettingsRounded";
|
||||||
import FolderIcon from "@mui/icons-material/FolderRounded";
|
import FolderIcon from "@mui/icons-material/FolderRounded";
|
||||||
@ -9,8 +9,10 @@ import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
|||||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
import { ButtonConfig } from '../../types/sidebar';
|
import { ButtonConfig } from '../../types/sidebar';
|
||||||
import './QuickAccessBar.css';
|
import './quickAccessBar/QuickAccessBar.css';
|
||||||
import AllToolsNavButton from './AllToolsNavButton';
|
import AllToolsNavButton from './AllToolsNavButton';
|
||||||
|
import { Tooltip } from './Tooltip';
|
||||||
|
import TopToolIndicator from './quickAccessBar/TopToolIndicator';
|
||||||
|
|
||||||
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
@ -149,6 +151,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
>
|
>
|
||||||
{/* Fixed header outside scrollable area */}
|
{/* Fixed header outside scrollable area */}
|
||||||
<div className="quick-access-header">
|
<div className="quick-access-header">
|
||||||
|
<TopToolIndicator activeButton={activeButton} setActiveButton={setActiveButton} />
|
||||||
<AllToolsNavButton activeButton={activeButton} setActiveButton={setActiveButton} />
|
<AllToolsNavButton activeButton={activeButton} setActiveButton={setActiveButton} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -175,12 +178,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
<Stack gap="lg" align="center">
|
<Stack gap="lg" align="center">
|
||||||
{buttonConfigs.slice(0, -1).map((config, index) => (
|
{buttonConfigs.slice(0, -1).map((config, index) => (
|
||||||
<React.Fragment key={config.id}>
|
<React.Fragment key={config.id}>
|
||||||
<Tooltip label={config.tooltip} position="right">
|
<Tooltip content={config.tooltip} sidebarTooltip>
|
||||||
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={config.size || 'xl'}
|
size={isButtonActive(config) ? (config.size || 'xl') : 'lg'}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={config.onClick}
|
onClick={() => {
|
||||||
|
config.onClick();
|
||||||
|
}}
|
||||||
style={getButtonStyle(config)}
|
style={getButtonStyle(config)}
|
||||||
className={isButtonActive(config) ? 'activeIconScale' : ''}
|
className={isButtonActive(config) ? 'activeIconScale' : ''}
|
||||||
data-testid={`${config.id}-button`}
|
data-testid={`${config.id}-button`}
|
||||||
@ -213,7 +218,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
{buttonConfigs
|
{buttonConfigs
|
||||||
.filter(config => config.id === 'config')
|
.filter(config => config.id === 'config')
|
||||||
.map(config => (
|
.map(config => (
|
||||||
<Tooltip key={config.id} label={config.tooltip} position="right">
|
<Tooltip key={config.id} content={config.tooltip} sidebarTooltip>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={config.size || 'lg'}
|
size={config.size || 'lg'}
|
||||||
|
@ -69,6 +69,8 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.all-tools-text.active {
|
.all-tools-text.active {
|
||||||
@ -111,6 +113,21 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow wrapping under the active top indicator; constrain to two lines */
|
||||||
|
.current-tool-label {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2; /* show up to two lines */
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-text.active {
|
.button-text.active {
|
||||||
@ -176,4 +193,63 @@
|
|||||||
.quick-access-bar {
|
.quick-access-bar {
|
||||||
scrollbar-width: auto;
|
scrollbar-width: auto;
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated current tool indicator that slides in from the top and pushes content down */
|
||||||
|
/* Container grows down so it pushes items below during animation */
|
||||||
|
.current-tool-slot {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 450ms ease-out, opacity 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-tool-enter {
|
||||||
|
animation: currentToolGrowDown 450ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-tool-slot.visible {
|
||||||
|
max-height: 96px; /* icon + label + divider */
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Replay the grow-down animation when switching tools while visible */
|
||||||
|
.current-tool-slot.replay .current-tool-content {
|
||||||
|
animation: currentToolGrowDown 450ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes currentToolGrowDown {
|
||||||
|
0% {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
max-height: 90px; /* enough space for icon + label */
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider that animates growing from top */
|
||||||
|
.current-tool-divider {
|
||||||
|
width: 3.75rem;
|
||||||
|
border-color: var(--color-gray-300);
|
||||||
|
margin: 0.5rem auto 0.5rem auto;
|
||||||
|
transform-origin: top;
|
||||||
|
animation: dividerGrowDown 350ms ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dividerGrowDown {
|
||||||
|
0% {
|
||||||
|
transform: scaleY(0);
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
opacity: 1;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
@ -0,0 +1,119 @@
|
|||||||
|
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 { Tooltip } from '../Tooltip';
|
||||||
|
|
||||||
|
interface TopToolIndicatorProps {
|
||||||
|
activeButton: string;
|
||||||
|
setActiveButton: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_IDS = ['read','sign','automate','files','activity','config'];
|
||||||
|
|
||||||
|
const TopToolIndicator: React.FC<TopToolIndicatorProps> = ({ activeButton, setActiveButton }) => {
|
||||||
|
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
|
||||||
|
|
||||||
|
// Determine if the indicator should be visible
|
||||||
|
const indicatorShouldShow = Boolean(
|
||||||
|
selectedToolKey && selectedTool && activeButton === 'tools' && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local animation and hover state
|
||||||
|
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
|
||||||
|
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
|
||||||
|
const [replayAnim, setReplayAnim] = useState<boolean>(false);
|
||||||
|
const [isAnimating, setIsAnimating] = useState<boolean>(false);
|
||||||
|
const [isBackHover, setIsBackHover] = useState<boolean>(false);
|
||||||
|
const prevKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (indicatorShouldShow) {
|
||||||
|
// If switching to a different tool while visible, replay the grow down
|
||||||
|
if (prevKeyRef.current && prevKeyRef.current !== selectedToolKey) {
|
||||||
|
setIndicatorTool(selectedTool);
|
||||||
|
setIndicatorVisible(true);
|
||||||
|
setReplayAnim(true);
|
||||||
|
setIsAnimating(true);
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
setReplayAnim(false);
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 500);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}
|
||||||
|
// First show
|
||||||
|
setIndicatorTool(selectedTool);
|
||||||
|
setIndicatorVisible(true);
|
||||||
|
setIsAnimating(true);
|
||||||
|
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||||
|
const tShow = window.setTimeout(() => setIsAnimating(false), 500);
|
||||||
|
return () => window.clearTimeout(tShow);
|
||||||
|
} else if (indicatorTool) {
|
||||||
|
// trigger collapse
|
||||||
|
setIndicatorVisible(false);
|
||||||
|
setIsAnimating(true);
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setIndicatorTool(null);
|
||||||
|
prevKeyRef.current = null;
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 500); // match CSS transition duration
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [indicatorShouldShow, selectedTool, selectedToolKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`current-tool-slot ${indicatorVisible ? 'visible' : ''} ${replayAnim ? 'replay' : ''}`}>
|
||||||
|
{indicatorTool && (
|
||||||
|
<div className="current-tool-content">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} sidebarTooltip>
|
||||||
|
<ActionIcon
|
||||||
|
size={'xl'}
|
||||||
|
variant="subtle"
|
||||||
|
onMouseEnter={() => setIsBackHover(true)}
|
||||||
|
onMouseLeave={() => setIsBackHover(false)}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveButton('tools');
|
||||||
|
handleBackToTools();
|
||||||
|
}}
|
||||||
|
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isBackHover ? '#9CA3AF' : 'var(--icon-tools-bg)',
|
||||||
|
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="iconContainer">
|
||||||
|
{isBackHover ? (
|
||||||
|
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
|
||||||
|
) : (
|
||||||
|
indicatorTool.icon
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<FitText
|
||||||
|
as="span"
|
||||||
|
text={indicatorTool.name}
|
||||||
|
lines={2}
|
||||||
|
minimumFontScale={0.4}
|
||||||
|
className="button-text active current-tool-label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(indicatorTool && !isAnimating) && (
|
||||||
|
<Divider size="xs" className="current-tool-divider" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopToolIndicator;
|
||||||
|
|
||||||
|
|
60
frontend/src/utils/FitText.tsx
Normal file
60
frontend/src/utils/FitText.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import React, { CSSProperties, useMemo, useRef } from 'react';
|
||||||
|
import { useAdjustFontSizeToFit } from './textFit';
|
||||||
|
|
||||||
|
type FitTextProps = {
|
||||||
|
text: string;
|
||||||
|
fontSize?: number; // px; if omitted, uses computed style
|
||||||
|
minimumFontScale?: number; // 0..1
|
||||||
|
lines?: number; // max lines
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
as?: 'span' | 'div';
|
||||||
|
};
|
||||||
|
|
||||||
|
const FitText: React.FC<FitTextProps> = ({
|
||||||
|
text,
|
||||||
|
fontSize,
|
||||||
|
minimumFontScale = 0.8,
|
||||||
|
lines = 1,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
as = 'span',
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Hook runs after mount and on size/text changes; uses observers internally
|
||||||
|
useAdjustFontSizeToFit(ref as any, {
|
||||||
|
maxFontSizePx: fontSize,
|
||||||
|
minFontScale: minimumFontScale,
|
||||||
|
maxLines: lines,
|
||||||
|
singleLine: lines === 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Memoize the HTML tag to render (span/div) from the `as` prop so
|
||||||
|
// React doesn't create a new component function on each render.
|
||||||
|
const ElementTag: any = useMemo(() => as, [as]);
|
||||||
|
|
||||||
|
const clampStyles: CSSProperties = {
|
||||||
|
// Multi-line clamp with ellipsis fallback
|
||||||
|
whiteSpace: lines === 1 ? 'nowrap' : 'normal',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: lines > 1 ? ('-webkit-box' as any) : undefined,
|
||||||
|
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',
|
||||||
|
fontSize: fontSize ? `${fontSize}px` : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ElementTag ref={ref} className={className} style={{ ...clampStyles, ...style }}>
|
||||||
|
{text}
|
||||||
|
</ElementTag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FitText;
|
||||||
|
|
||||||
|
|
98
frontend/src/utils/textFit.ts
Normal file
98
frontend/src/utils/textFit.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
element.style.wordBreak = 'break-word';
|
||||||
|
element.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
try { mo.disconnect(); } catch {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user