mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-22 12:19:24 +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 { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
|
||||
@ -9,7 +10,7 @@ interface AllToolsNavButtonProps {
|
||||
}
|
||||
|
||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const { handleReaderToggle, handleBackToTools } = useToolWorkflow();
|
||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||
|
||||
const handleClick = () => {
|
||||
setActiveButton('tools');
|
||||
@ -18,19 +19,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
||||
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 = (
|
||||
<span className="iconContainer">
|
||||
<AppsIcon sx={{ fontSize: '1.75rem' }} />
|
||||
<AppsIcon sx={{ fontSize: '1.5rem' }} />
|
||||
</span>
|
||||
);
|
||||
|
||||
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">
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
size={'lg'}
|
||||
variant="subtle"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
@ -39,6 +41,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
className={isActive ? 'activeIconScale' : ''}
|
||||
>
|
||||
{iconNode}
|
||||
</ActionIcon>
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 SettingsIcon from "@mui/icons-material/SettingsRounded";
|
||||
import FolderIcon from "@mui/icons-material/FolderRounded";
|
||||
@ -9,8 +9,10 @@ import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { ButtonConfig } from '../../types/sidebar';
|
||||
import './QuickAccessBar.css';
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import TopToolIndicator from './quickAccessBar/TopToolIndicator';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
}, ref) => {
|
||||
@ -149,6 +151,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
>
|
||||
{/* Fixed header outside scrollable area */}
|
||||
<div className="quick-access-header">
|
||||
<TopToolIndicator activeButton={activeButton} setActiveButton={setActiveButton} />
|
||||
<AllToolsNavButton activeButton={activeButton} setActiveButton={setActiveButton} />
|
||||
|
||||
</div>
|
||||
@ -175,12 +178,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
<Stack gap="lg" align="center">
|
||||
{buttonConfigs.slice(0, -1).map((config, index) => (
|
||||
<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" }}>
|
||||
<ActionIcon
|
||||
size={config.size || 'xl'}
|
||||
size={isButtonActive(config) ? (config.size || 'xl') : 'lg'}
|
||||
variant="subtle"
|
||||
onClick={config.onClick}
|
||||
onClick={() => {
|
||||
config.onClick();
|
||||
}}
|
||||
style={getButtonStyle(config)}
|
||||
className={isButtonActive(config) ? 'activeIconScale' : ''}
|
||||
data-testid={`${config.id}-button`}
|
||||
@ -213,7 +218,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
{buttonConfigs
|
||||
.filter(config => config.id === '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">
|
||||
<ActionIcon
|
||||
size={config.size || 'lg'}
|
||||
|
@ -69,6 +69,8 @@
|
||||
font-size: 0.75rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.all-tools-text.active {
|
||||
@ -111,6 +113,21 @@
|
||||
font-size: 0.75rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
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 {
|
||||
@ -177,3 +194,62 @@
|
||||
scrollbar-width: auto;
|
||||
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