diff --git a/frontend/src/components/shared/AllToolsNavButton.tsx b/frontend/src/components/shared/AllToolsNavButton.tsx index be4f25038..cd1824693 100644 --- a/frontend/src/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/components/shared/AllToolsNavButton.tsx @@ -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 = ({ activeButton, setActiveButton }) => { - const { handleReaderToggle, handleBackToTools } = useToolWorkflow(); + const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); const handleClick = () => { setActiveButton('tools'); @@ -18,19 +19,20 @@ const AllToolsNavButton: React.FC = ({ 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 = ( - + ); return ( - +
= ({ activeButton, set border: 'none', borderRadius: '8px', }} + className={isActive ? 'activeIconScale' : ''} > {iconNode} diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 549def24b..5274249f6 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -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(({ }, ref) => { @@ -149,6 +151,7 @@ const QuickAccessBar = forwardRef(({ > {/* Fixed header outside scrollable area */}
+
@@ -175,12 +178,14 @@ const QuickAccessBar = forwardRef(({ {buttonConfigs.slice(0, -1).map((config, index) => ( - +
{ + config.onClick(); + }} style={getButtonStyle(config)} className={isButtonActive(config) ? 'activeIconScale' : ''} data-testid={`${config.id}-button`} @@ -213,7 +218,7 @@ const QuickAccessBar = forwardRef(({ {buttonConfigs .filter(config => config.id === 'config') .map(config => ( - +
void; +} + +const NAV_IDS = ['read','sign','automate','files','activity','config']; + +const TopToolIndicator: React.FC = ({ 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(null); + const [indicatorVisible, setIndicatorVisible] = useState(false); + const [replayAnim, setReplayAnim] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [isBackHover, setIsBackHover] = useState(false); + const prevKeyRef = useRef(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 ( + <> +
+ {indicatorTool && ( +
+
+ + 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' + }} + > + + {isBackHover ? ( + + ) : ( + indicatorTool.icon + )} + + + + +
+
+ )} +
+ {(indicatorTool && !isAnimating) && ( + + )} + + ); +}; + +export default TopToolIndicator; + + diff --git a/frontend/src/utils/FitText.tsx b/frontend/src/utils/FitText.tsx new file mode 100644 index 000000000..485c08cb5 --- /dev/null +++ b/frontend/src/utils/FitText.tsx @@ -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 = ({ + text, + fontSize, + minimumFontScale = 0.8, + lines = 1, + className, + style, + as = 'span', +}) => { + const ref = useRef(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 ( + + {text} + + ); +}; + +export default FitText; + + diff --git a/frontend/src/utils/textFit.ts b/frontend/src/utils/textFit.ts new file mode 100644 index 000000000..c9d85bb48 --- /dev/null +++ b/frontend/src/utils/textFit.ts @@ -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, + 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]); +} + +