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:
EthanHealy01 2025-08-13 14:51:37 +01:00
parent 39fed225a1
commit 3c2524183c
6 changed files with 373 additions and 12 deletions

View File

@ -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>

View File

@ -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'}

View File

@ -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;
}
} }

View File

@ -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;

View 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;

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