mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feature/v2/all tools sidebar (#4151)
# Description of Changes - Added the all tools sidebar - Added a TextFit component that shrinks text to fit containers - Added a TopToolIcon on the nav, that animates down to give users feedback on what tool is selected - Added the baseToolRegistry, to replace the old pattern of listing tools, allowing us to clean up the ToolRegistry code - Fixed Mantine light/dark theme race condition - General styling tweaks --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
|
||||
@@ -28,9 +28,9 @@ export default function Workbench() {
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSidebarsVisible
|
||||
} = useWorkbenchState();
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { selectedToolKey, selectedTool, handleToolSelect } = useToolSelection();
|
||||
const { selectedToolKey, selectedTool, handleToolSelect } = useToolWorkflow();
|
||||
const { addToActiveFiles } = useFileHandler();
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
|
||||
interface AllToolsNavButtonProps {
|
||||
activeButton: string;
|
||||
setActiveButton: (id: string) => void;
|
||||
}
|
||||
|
||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||
|
||||
const handleClick = () => {
|
||||
setActiveButton('tools');
|
||||
// Preserve existing behavior used in QuickAccessBar header
|
||||
handleReaderToggle();
|
||||
handleBackToTools();
|
||||
};
|
||||
|
||||
// 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.5rem' }} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||
<ActionIcon
|
||||
size={'lg'}
|
||||
variant="subtle"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
className={isActive ? 'activeIconScale' : ''}
|
||||
>
|
||||
{iconNode}
|
||||
</ActionIcon>
|
||||
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
|
||||
{t("quickAccess.allTools", "All Tools")}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllToolsNavButton;
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { CSSProperties, useMemo, useRef } from 'react';
|
||||
import { useAdjustFontSizeToFit } from './fitText/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';
|
||||
/**
|
||||
* Insert zero-width soft breaks after these characters to prefer wrapping at them
|
||||
* when multi-line is enabled. Defaults to '/'. Ignored when lines === 1.
|
||||
*/
|
||||
softBreakChars?: string | string[];
|
||||
};
|
||||
|
||||
const FitText: React.FC<FitTextProps> = ({
|
||||
text,
|
||||
fontSize,
|
||||
minimumFontScale = 0.8,
|
||||
lines = 1,
|
||||
className,
|
||||
style,
|
||||
as = 'span',
|
||||
softBreakChars = ['-','_','/'],
|
||||
}) => {
|
||||
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]);
|
||||
|
||||
// For the / character, insert zero-width soft breaks to prefer wrapping at them
|
||||
const displayText = useMemo(() => {
|
||||
if (!text) return text;
|
||||
if (!lines || lines <= 1) return text;
|
||||
const chars = Array.isArray(softBreakChars) ? softBreakChars : [softBreakChars];
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(${chars.filter(Boolean).map(esc).join('|')})`, 'g');
|
||||
return text.replace(re, `$1\u200B`);
|
||||
}, [text, lines, softBreakChars]);
|
||||
|
||||
const clampStyles: CSSProperties = {
|
||||
// Multi-line clamp with ellipsis fallback
|
||||
whiteSpace: lines === 1 ? 'nowrap' : 'normal',
|
||||
overflow: 'visible',
|
||||
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,
|
||||
// Favor shrinking over breaking words; only break at natural spaces or softBreakChars
|
||||
wordBreak: lines > 1 ? ('keep-all' as any) : ('normal' as any),
|
||||
overflowWrap: 'normal',
|
||||
hyphens: 'manual',
|
||||
// fontSize expects rem values (e.g., 1.2, 0.9) to scale with global font size
|
||||
fontSize: fontSize ? `${fontSize}rem` : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<ElementTag ref={ref} className={className} style={{ ...clampStyles, ...style }}>
|
||||
{displayText}
|
||||
</ElementTag>
|
||||
);
|
||||
};
|
||||
|
||||
export default FitText;
|
||||
|
||||
|
||||
@@ -1,143 +1,93 @@
|
||||
import React, { useState, useRef, forwardRef } from "react";
|
||||
import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core";
|
||||
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import { ActionIcon, Stack, Divider } from "@mantine/core";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
|
||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||
import SettingsIcon from "@mui/icons-material/SettingsRounded";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesomeRounded";
|
||||
import FolderIcon from "@mui/icons-material/FolderRounded";
|
||||
import PersonIcon from "@mui/icons-material/PersonRounded";
|
||||
import NotificationsIcon from "@mui/icons-material/NotificationsRounded";
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { ButtonConfig } from '../../types/sidebar';
|
||||
import './QuickAccessBar.css';
|
||||
|
||||
function NavHeader({
|
||||
activeButton,
|
||||
setActiveButton
|
||||
}: {
|
||||
activeButton: string;
|
||||
setActiveButton: (id: string) => void;
|
||||
}) {
|
||||
const { handleReaderToggle, handleBackToTools } = useToolWorkflow();
|
||||
return (
|
||||
<>
|
||||
<div className="nav-header">
|
||||
<Tooltip label="User Profile" position="right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
className="action-icon-style"
|
||||
>
|
||||
<PersonIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Notifications" position="right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
className="action-icon-style"
|
||||
>
|
||||
<NotificationsIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Divider after top icons */}
|
||||
<Divider
|
||||
size="xs"
|
||||
className="nav-header-divider"
|
||||
/>
|
||||
{/* All Tools button below divider */}
|
||||
<Tooltip label="View all available tools" position="right">
|
||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setActiveButton('tools');
|
||||
handleReaderToggle();
|
||||
handleBackToTools();
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||
color: activeButton === 'tools' ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
className={activeButton === 'tools' ? 'activeIconScale' : ''}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
<AppsIcon sx={{ fontSize: "1.75rem" }} />
|
||||
</span>
|
||||
</ActionIcon>
|
||||
<span className={`all-tools-text ${activeButton === 'tools' ? 'active' : 'inactive'}`}>
|
||||
All Tools
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
getActiveNavButton,
|
||||
} from './quickAccessBar/QuickAccessBar';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle } = useToolWorkflow();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode } = useToolWorkflow();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const isOverflow = useIsOverflowing(scrollableRef);
|
||||
|
||||
useEffect(() => {
|
||||
const next = getActiveNavButton(selectedToolKey, readerMode);
|
||||
setActiveButton(next);
|
||||
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode]);
|
||||
|
||||
const handleFilesButtonClick = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
|
||||
const buttonConfigs: ButtonConfig[] = [
|
||||
{
|
||||
id: 'read',
|
||||
name: 'Read',
|
||||
name: t("quickAccess.read", "Read"),
|
||||
icon: <MenuBookIcon sx={{ fontSize: "1.5rem" }} />,
|
||||
tooltip: 'Read documents',
|
||||
size: 'lg',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
setActiveButton('read');
|
||||
handleBackToTools();
|
||||
handleReaderToggle();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'sign',
|
||||
name: 'Sign',
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
signature
|
||||
</span>,
|
||||
tooltip: 'Sign your document',
|
||||
name: t("quickAccess.sign", "Sign"),
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
signature
|
||||
</span>,
|
||||
size: 'lg',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => setActiveButton('sign')
|
||||
onClick: () => {
|
||||
setActiveButton('sign');
|
||||
handleToolSelect('sign');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'automate',
|
||||
name: 'Automate',
|
||||
icon: <AutoAwesomeIcon sx={{ fontSize: "1.5rem" }} />,
|
||||
tooltip: 'Automate workflows',
|
||||
name: t("quickAccess.automate", "Automate"),
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
automation
|
||||
</span>,
|
||||
size: 'lg',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => setActiveButton('automate')
|
||||
onClick: () => {
|
||||
setActiveButton('automate');
|
||||
handleToolSelect('automate');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
name: 'Files',
|
||||
icon: <FolderIcon sx={{ fontSize: "1.5rem" }} />,
|
||||
tooltip: 'Manage files',
|
||||
name: t("quickAccess.files", "Files"),
|
||||
icon: <FolderIcon sx={{ fontSize: "1.25rem" }} />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
@@ -145,12 +95,11 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
name: 'Activity',
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
vital_signs
|
||||
</span>,
|
||||
tooltip: 'View activity and analytics',
|
||||
name: t("quickAccess.activity", "Activity"),
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
vital_signs
|
||||
</span>,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
type: 'navigation',
|
||||
@@ -158,9 +107,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
name: 'Config',
|
||||
name: t("quickAccess.config", "Config"),
|
||||
icon: <SettingsIcon sx={{ fontSize: "1rem" }} />,
|
||||
tooltip: 'Configure settings',
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
@@ -169,60 +117,28 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
}
|
||||
];
|
||||
|
||||
const CIRCULAR_BORDER_RADIUS = '50%';
|
||||
const ROUND_BORDER_RADIUS = '8px';
|
||||
|
||||
const getBorderRadius = (config: ButtonConfig): string => {
|
||||
return config.isRound ? CIRCULAR_BORDER_RADIUS : ROUND_BORDER_RADIUS;
|
||||
};
|
||||
|
||||
const isButtonActive = (config: ButtonConfig): boolean => {
|
||||
return (
|
||||
(config.type === 'navigation' && activeButton === config.id) ||
|
||||
(config.type === 'modal' && config.id === 'files' && isFilesModalOpen) ||
|
||||
(config.type === 'modal' && config.id === 'config' && configModalOpen)
|
||||
);
|
||||
};
|
||||
|
||||
const getButtonStyle = (config: ButtonConfig) => {
|
||||
const isActive = isButtonActive(config);
|
||||
|
||||
if (isActive) {
|
||||
return {
|
||||
backgroundColor: `var(--icon-${config.id}-bg)`,
|
||||
color: `var(--icon-${config.id}-color)`,
|
||||
border: 'none',
|
||||
borderRadius: getBorderRadius(config),
|
||||
};
|
||||
}
|
||||
|
||||
// Inactive state for all buttons
|
||||
return {
|
||||
backgroundColor: 'var(--icon-inactive-bg)',
|
||||
color: 'var(--icon-inactive-color)',
|
||||
border: 'none',
|
||||
borderRadius: getBorderRadius(config),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="quick-access"
|
||||
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||
style={{
|
||||
borderRight: '1px solid var(--border-default)'
|
||||
}}
|
||||
>
|
||||
{/* Fixed header outside scrollable area */}
|
||||
<div className="quick-access-header">
|
||||
<NavHeader
|
||||
activeButton={activeButton}
|
||||
setActiveButton={setActiveButton}
|
||||
/>
|
||||
<ActiveToolButton activeButton={activeButton} setActiveButton={setActiveButton} />
|
||||
<AllToolsNavButton activeButton={activeButton} setActiveButton={setActiveButton} />
|
||||
|
||||
</div>
|
||||
|
||||
{/* Conditional divider when overflowing */}
|
||||
{isOverflow && (
|
||||
<Divider
|
||||
size="xs"
|
||||
<Divider
|
||||
size="xs"
|
||||
className="overflow-divider"
|
||||
/>
|
||||
)}
|
||||
@@ -241,63 +157,63 @@ 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">
|
||||
|
||||
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
||||
<ActionIcon
|
||||
size={config.size || 'xl'}
|
||||
size={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? (config.size || 'xl') : 'lg'}
|
||||
variant="subtle"
|
||||
onClick={config.onClick}
|
||||
style={getButtonStyle(config)}
|
||||
className={isButtonActive(config) ? 'activeIconScale' : ''}
|
||||
onClick={() => {
|
||||
config.onClick();
|
||||
}}
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
|
||||
data-testid={`${config.id}-button`}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{config.icon}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
<span className={`button-text ${isButtonActive(config) ? 'active' : 'inactive'}`}>
|
||||
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
|
||||
{/* Add divider after Automate button (index 2) */}
|
||||
{index === 2 && (
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
|
||||
{/* Spacer to push Config button to bottom */}
|
||||
<div className="spacer" />
|
||||
|
||||
|
||||
{/* Config button at the bottom */}
|
||||
{buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<Tooltip key={config.id} label={config.tooltip} position="right">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size={config.size || 'lg'}
|
||||
variant="subtle"
|
||||
onClick={config.onClick}
|
||||
style={getButtonStyle(config)}
|
||||
className={isButtonActive(config) ? 'activeIconScale' : ''}
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
|
||||
data-testid={`${config.id}-button`}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{config.icon}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
<span className={`button-text ${isButtonActive(config) ? 'active' : 'inactive'}`}>
|
||||
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
@@ -34,22 +34,19 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColorSchemeScript defaultColorScheme={mantineColorScheme} />
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
theme={mantineTheme}
|
||||
defaultColorScheme={mantineColorScheme}
|
||||
forceColorScheme={mantineColorScheme}
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
theme={mantineTheme}
|
||||
defaultColorScheme={mantineColorScheme}
|
||||
forceColorScheme={mantineColorScheme}
|
||||
>
|
||||
<div
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
<div
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
</>
|
||||
{children}
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import styles from './textInput/TextInput.module.css';
|
||||
|
||||
/**
|
||||
* Props for the TextInput component
|
||||
*/
|
||||
export interface TextInputProps {
|
||||
/** The input value (required) */
|
||||
value: string;
|
||||
/** Callback when input value changes (required) */
|
||||
onChange: (value: string) => void;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Optional left icon */
|
||||
icon?: React.ReactNode;
|
||||
/** Whether to show the clear button (default: true) */
|
||||
showClearButton?: boolean;
|
||||
/** Custom clear handler (defaults to setting value to empty string) */
|
||||
onClear?: () => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Additional inline styles */
|
||||
style?: React.CSSProperties;
|
||||
/** HTML autocomplete attribute (default: 'off') */
|
||||
autoComplete?: string;
|
||||
/** Whether the input is disabled (default: false) */
|
||||
disabled?: boolean;
|
||||
/** Whether the input is read-only (default: false) */
|
||||
readOnly?: boolean;
|
||||
/** Accessibility label */
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
icon,
|
||||
showClearButton = true,
|
||||
onClear,
|
||||
className = '',
|
||||
style,
|
||||
autoComplete = 'off',
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const handleClear = () => {
|
||||
if (onClear) {
|
||||
onClear();
|
||||
} else {
|
||||
onChange('');
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowClearButton = showClearButton && value.trim().length > 0 && !disabled && !readOnly;
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} ${className}`} style={style}>
|
||||
{icon && (
|
||||
<span
|
||||
className={styles.icon}
|
||||
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
autoComplete={autoComplete}
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
aria-label={ariaLabel}
|
||||
style={{
|
||||
backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF',
|
||||
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',
|
||||
paddingRight: shouldShowClearButton ? '40px' : '12px',
|
||||
paddingLeft: icon ? '40px' : '12px',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{shouldShowClearButton && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearButton}
|
||||
onClick={handleClear}
|
||||
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
||||
aria-label="Clear input"
|
||||
>
|
||||
<span className="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export interface TooltipProps {
|
||||
children: React.ReactElement;
|
||||
offset?: number;
|
||||
maxWidth?: number | string;
|
||||
minWidth?: number | string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
arrow?: boolean;
|
||||
@@ -23,6 +24,8 @@ export interface TooltipProps {
|
||||
title: string;
|
||||
logo?: React.ReactNode;
|
||||
};
|
||||
delay?: number;
|
||||
containerStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
@@ -32,18 +35,28 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
tips,
|
||||
children,
|
||||
offset: gap = 8,
|
||||
maxWidth = 280,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
arrow = false,
|
||||
portalTarget,
|
||||
header,
|
||||
delay = 0,
|
||||
containerStyle={},
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const triggerRef = useRef<HTMLElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = () => {
|
||||
if (openTimeoutRef.current) {
|
||||
clearTimeout(openTimeoutRef.current);
|
||||
openTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get sidebar context for tooltip positioning
|
||||
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
|
||||
@@ -53,6 +66,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
clearTimers();
|
||||
if (isControlled) {
|
||||
onOpenChange?.(newOpen);
|
||||
} else {
|
||||
@@ -63,6 +77,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
if (!newOpen) {
|
||||
setIsPinned(false);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handleTooltipClick = (e: React.MouseEvent) => {
|
||||
@@ -97,6 +112,13 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
}, [isPinned]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const getArrowClass = () => {
|
||||
// No arrow for sidebar tooltips
|
||||
if (sidebarTooltip) return null;
|
||||
@@ -118,8 +140,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
'';
|
||||
};
|
||||
|
||||
// Only show tooltip when position is ready and correct
|
||||
const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true);
|
||||
// Always mount when open so we can measure; hide until positioned to avoid flash
|
||||
const shouldShowTooltip = open;
|
||||
|
||||
const tooltipElement = shouldShowTooltip ? (
|
||||
<div
|
||||
@@ -128,11 +150,13 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
position: 'fixed',
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
maxWidth,
|
||||
width: (maxWidth !== undefined ? maxWidth : '25rem'),
|
||||
minWidth: minWidth,
|
||||
zIndex: 9999,
|
||||
visibility: 'visible',
|
||||
opacity: 1,
|
||||
visibility: positionReady ? 'visible' : 'hidden',
|
||||
opacity: positionReady ? 1 : 0,
|
||||
color: 'var(--text-primary)',
|
||||
...containerStyle,
|
||||
}}
|
||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||
onClick={handleTooltipClick}
|
||||
@@ -176,27 +200,23 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
) : null;
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
// Clear any existing timeout
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Only show on hover if not pinned
|
||||
clearTimers();
|
||||
if (!isPinned) {
|
||||
handleOpenChange(true);
|
||||
const effectiveDelay = Math.max(0, delay || 0);
|
||||
openTimeoutRef.current = setTimeout(() => {
|
||||
handleOpenChange(true);
|
||||
}, effectiveDelay);
|
||||
}
|
||||
|
||||
(children.props as any)?.onMouseEnter?.(e);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
// Only hide on mouse leave if not pinned
|
||||
clearTimers();
|
||||
openTimeoutRef.current = null;
|
||||
|
||||
if (!isPinned) {
|
||||
// Add a small delay to prevent flickering
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
handleOpenChange(false);
|
||||
}, 100);
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
(children.props as any)?.onMouseLeave?.(e);
|
||||
@@ -207,6 +227,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
if (open) {
|
||||
setIsPinned(!isPinned);
|
||||
} else {
|
||||
clearTimers();
|
||||
handleOpenChange(true);
|
||||
setIsPinned(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# FitText Component
|
||||
|
||||
Adaptive text component that automatically scales font size down so the content fits within its container, with optional multi-line clamping. Built with a small hook wrapper around ResizeObserver and MutationObserver for reliable, responsive fitting.
|
||||
|
||||
## Features
|
||||
|
||||
- 📏 Auto-fit text to available width (and optional line count)
|
||||
- 🧵 Single-line and multi-line support with clamping and ellipsis
|
||||
- 🔁 React hook + component interface
|
||||
- ⚡ Efficient: observers and rAF, minimal layout thrash
|
||||
- 🎛️ Configurable min scale, max font size, and step size
|
||||
|
||||
## Behavior
|
||||
|
||||
- On mount and whenever size/text changes, the font is reduced (never increased) until the text fits the given constraints.
|
||||
- If `lines` is provided, height is constrained to an estimated maximum based on computed line-height.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import FitText from '@/components/shared/FitText';
|
||||
|
||||
export function CardTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<FitText text={title} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `text` | `string` | — | The string to render and fit |
|
||||
| `fontSize` | `number` | computed | Maximum starting font size in rem (e.g., 1.2, 0.9) |
|
||||
| `minimumFontScale` | `number` | `0.8` | Smallest scale relative to the max (0..1) |
|
||||
| `lines` | `number` | `1` | Maximum number of lines to display and fit |
|
||||
| `className` | `string` | — | Optional class on the rendered element |
|
||||
| `style` | `CSSProperties` | — | Inline styles (merged with internal clamp styles) |
|
||||
| `as` | `'span' | 'div'` | `'span'` | HTML tag to render |
|
||||
|
||||
Notes:
|
||||
- For multi-line, the component applies WebKit line clamping (with reasonable fallbacks) and fits within that height.
|
||||
- The component only scales down; if the content already fits, it keeps the starting size.
|
||||
|
||||
## Examples
|
||||
|
||||
### Single-line title (default)
|
||||
|
||||
```tsx
|
||||
<FitText text="Very long single-line title that should shrink" />
|
||||
```
|
||||
|
||||
### Multi-line label (up to 3 lines)
|
||||
|
||||
```tsx
|
||||
<FitText
|
||||
text="This label can wrap up to three lines and will shrink so it fits nicely"
|
||||
lines={3}
|
||||
minimumFontScale={0.6}
|
||||
className="my-multiline-label"
|
||||
/>
|
||||
```
|
||||
|
||||
### Explicit starting size
|
||||
|
||||
```tsx
|
||||
<FitText text="Starts at 1.2rem, scales down if needed" fontSize={1.2} />
|
||||
```
|
||||
|
||||
### Render as a div
|
||||
|
||||
```tsx
|
||||
<FitText as="div" text="Block-level content" lines={2} />
|
||||
```
|
||||
|
||||
## Hook Usage (Advanced)
|
||||
|
||||
If you need to control your own element, you can use the underlying hook directly.
|
||||
|
||||
```tsx
|
||||
import React, { useRef } from 'react';
|
||||
import { useAdjustFontSizeToFit } from '@/components/shared/fitText/textFit';
|
||||
|
||||
export function CustomFit() {
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
useAdjustFontSizeToFit(ref as any, {
|
||||
maxFontSizePx: 20,
|
||||
minFontScale: 0.6,
|
||||
maxLines: 2,
|
||||
singleLine: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<span ref={ref} style={{ display: 'inline-block', maxWidth: 240 }}>
|
||||
Arbitrary text that will scale to fit two lines.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- For predictable measurements, ensure the container has a fixed width (or stable layout) when fitting occurs.
|
||||
- Avoid animating width while fitting; update after animation completes for best results.
|
||||
- When you need more control of typography, pass `fontSize` to define the starting ceiling.
|
||||
- **Important**: The `fontSize` prop expects `rem` values (e.g., 1.2, 0.9) to ensure text scales with global font size changes.
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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 {}
|
||||
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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
||||
*
|
||||
* When a user selects a tool from the All Tools list, this component displays the tool's
|
||||
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
||||
* tool is currently active and offers a back button to return to the All Tools list.
|
||||
*
|
||||
* Features:
|
||||
* - Shows tool icon and name when a tool is selected
|
||||
* - Hover to reveal back arrow for returning to All Tools
|
||||
* - Smooth slide-down/slide-up animations
|
||||
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
|
||||
import FitText from '../FitText';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
interface ActiveToolButtonProps {
|
||||
activeButton: string;
|
||||
setActiveButton: (id: string) => void;
|
||||
}
|
||||
|
||||
const NAV_IDS = ['read', 'sign', 'automate'];
|
||||
|
||||
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
|
||||
|
||||
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
|
||||
const indicatorShouldShow = Boolean(
|
||||
selectedToolKey && 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);
|
||||
const collapseTimeoutRef = useRef<number | null>(null);
|
||||
const animTimeoutRef = useRef<number | null>(null);
|
||||
const replayRafRef = useRef<number | null>(null);
|
||||
|
||||
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey };
|
||||
|
||||
const clearTimers = () => {
|
||||
if (collapseTimeoutRef.current) {
|
||||
window.clearTimeout(collapseTimeoutRef.current);
|
||||
collapseTimeoutRef.current = null;
|
||||
}
|
||||
if (animTimeoutRef.current) {
|
||||
window.clearTimeout(animTimeoutRef.current);
|
||||
animTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const playGrowDown = () => {
|
||||
clearTimers();
|
||||
setIndicatorTool(selectedTool);
|
||||
setIndicatorVisible(true);
|
||||
// Force a replay even if the class is already applied
|
||||
setReplayAnim(false);
|
||||
if (replayRafRef.current) {
|
||||
cancelAnimationFrame(replayRafRef.current);
|
||||
replayRafRef.current = null;
|
||||
}
|
||||
replayRafRef.current = requestAnimationFrame(() => {
|
||||
setReplayAnim(true);
|
||||
});
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setReplayAnim(false);
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const firstShow = () => {
|
||||
clearTimers();
|
||||
setIndicatorTool(selectedTool);
|
||||
setIndicatorVisible(true);
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const triggerCollapse = () => {
|
||||
clearTimers();
|
||||
setIndicatorVisible(false);
|
||||
setIsAnimating(true);
|
||||
collapseTimeoutRef.current = window.setTimeout(() => {
|
||||
setIndicatorTool(null);
|
||||
prevKeyRef.current = null;
|
||||
setIsAnimating(false);
|
||||
collapseTimeoutRef.current = null;
|
||||
}, 500); // match CSS transition duration
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (indicatorShouldShow) {
|
||||
clearTimers();
|
||||
if (!indicatorVisible) {
|
||||
firstShow();
|
||||
return;
|
||||
}
|
||||
if (!indicatorTool) {
|
||||
firstShow();
|
||||
} else if (isSwitchingToNewTool()) {
|
||||
playGrowDown();
|
||||
} else {
|
||||
// keep reference in sync
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
}
|
||||
} else if (indicatorTool || indicatorVisible) {
|
||||
triggerCollapse();
|
||||
}
|
||||
}, [indicatorShouldShow, selectedTool, selectedToolKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
if (replayRafRef.current) {
|
||||
cancelAnimationFrame(replayRafRef.current);
|
||||
replayRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ overflow: 'visible' }} 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} position="right" arrow maxWidth={140}>
|
||||
<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 ? 'var(--color-gray-300)' : '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={3}
|
||||
minimumFontScale={0.4}
|
||||
className="button-text active current-tool-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveToolButton;
|
||||
|
||||
|
||||
+85
@@ -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,22 @@
|
||||
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: keep-all;
|
||||
overflow-wrap: normal;
|
||||
hyphens: manual;
|
||||
}
|
||||
|
||||
.button-text.active {
|
||||
@@ -176,4 +194,71 @@
|
||||
.quick-access-bar {
|
||||
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: 8.25rem; /* icon + up to 3-line label + divider (132px) */
|
||||
opacity: 1;
|
||||
border-bottom: 1px solid var(--color-gray-300);
|
||||
padding-bottom: 0.75rem; /* push border down for spacing */
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Replay the grow-down animation when switching tools while visible */
|
||||
.current-tool-slot.replay .current-tool-content {
|
||||
animation: currentToolGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
/* Also animate the container itself when replaying so it "pushes down" again */
|
||||
.current-tool-slot.replay {
|
||||
animation: currentToolGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes currentToolGrowDown {
|
||||
0% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 7.875rem; /* enough space for icon + up to 3-line label (126px) */
|
||||
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,83 @@
|
||||
import { ButtonConfig } from '../../../types/sidebar';
|
||||
|
||||
// Border radius constants
|
||||
export const ROUND_BORDER_RADIUS = '0.5rem';
|
||||
|
||||
/**
|
||||
* Check if a navigation button is currently active
|
||||
*/
|
||||
export const isNavButtonActive = (
|
||||
config: ButtonConfig,
|
||||
activeButton: string,
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
): boolean => {
|
||||
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
|
||||
const isActiveByContext =
|
||||
config.type === 'navigation' &&
|
||||
leftPanelView === 'toolContent' &&
|
||||
selectedToolKey === config.id;
|
||||
const isActiveByModal =
|
||||
(config.type === 'modal' && config.id === 'files' && isFilesModalOpen) ||
|
||||
(config.type === 'modal' && config.id === 'config' && configModalOpen);
|
||||
|
||||
return isActiveByLocalState || isActiveByContext || isActiveByModal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get button styles based on active state
|
||||
*/
|
||||
export const getNavButtonStyle = (
|
||||
config: ButtonConfig,
|
||||
activeButton: string,
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
) => {
|
||||
const isActive = isNavButtonActive(
|
||||
config,
|
||||
activeButton,
|
||||
isFilesModalOpen,
|
||||
configModalOpen,
|
||||
selectedToolKey,
|
||||
leftPanelView
|
||||
);
|
||||
|
||||
if (isActive) {
|
||||
return {
|
||||
backgroundColor: `var(--icon-${config.id}-bg)`,
|
||||
color: `var(--icon-${config.id}-color)`,
|
||||
border: 'none',
|
||||
borderRadius: ROUND_BORDER_RADIUS,
|
||||
};
|
||||
}
|
||||
|
||||
// Inactive state for all buttons
|
||||
return {
|
||||
backgroundColor: 'var(--icon-inactive-bg)',
|
||||
color: 'var(--icon-inactive-color)',
|
||||
border: 'none',
|
||||
borderRadius: ROUND_BORDER_RADIUS,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Determine the active nav button based on current tool state and registry
|
||||
*/
|
||||
export const getActiveNavButton = (
|
||||
selectedToolKey: string | null,
|
||||
readerMode: boolean
|
||||
): string => {
|
||||
// Reader mode takes precedence and should highlight the Read nav item
|
||||
if (readerMode) {
|
||||
return 'read';
|
||||
}
|
||||
// If a tool is selected, highlight it immediately even if the panel view
|
||||
// transition to 'toolContent' has not completed yet. This prevents a brief
|
||||
// period of no-highlight during rapid navigation.
|
||||
return selectedToolKey ? selectedToolKey : 'tools';
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
font-size: 16px;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--search-text-and-icon-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input:read-only {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .clearButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ A flexible, accessible tooltip component that supports both regular positioning
|
||||
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
|
||||
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
|
||||
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
|
||||
- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay`
|
||||
|
||||
## Behavior
|
||||
|
||||
@@ -61,6 +62,7 @@ function MyComponent() {
|
||||
| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element |
|
||||
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into |
|
||||
| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo |
|
||||
| `delay` | `number` | `0` | Optional hover-open delay (ms). If omitted or 0, opens immediately |
|
||||
|
||||
### TooltipTip Interface
|
||||
|
||||
@@ -106,6 +108,17 @@ interface TooltipTip {
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### Optional Hover Delay
|
||||
|
||||
```tsx
|
||||
// Show after a 1s hover
|
||||
<Tooltip content="Appears after a long hover" delay={1000} />
|
||||
|
||||
// Custom long-hover duration (2 seconds)
|
||||
<Tooltip content="Appears after 2s" delay={2000} />
|
||||
```
|
||||
|
||||
|
||||
### Custom JSX Content
|
||||
|
||||
```tsx
|
||||
@@ -214,6 +227,12 @@ Links automatically get proper styling with hover states and open in new tabs wh
|
||||
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
|
||||
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
|
||||
|
||||
## Timing Details
|
||||
|
||||
- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned).
|
||||
- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps.
|
||||
- Only one tooltip can be open at a time; hovering a new trigger closes others immediately.
|
||||
|
||||
### Sidebar Tooltips
|
||||
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
|
||||
- Vertical positioning follows the trigger but clamps to viewport
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
pointer-events: auto;
|
||||
z-index: 9999;
|
||||
transition: opacity 100ms ease-out, transform 100ms ease-out;
|
||||
min-width: 25rem;
|
||||
max-width: 50vh;
|
||||
max-height: 80vh;
|
||||
color: var(--text-primary);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Stack, Text } from '@mantine/core';
|
||||
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
|
||||
import ToolButton from './toolPicker/ToolButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolSections } from '../../hooks/useToolSections';
|
||||
import SubcategoryHeader from './shared/SubcategoryHeader';
|
||||
import NoToolsFound from './shared/NoToolsFound';
|
||||
|
||||
interface SearchResultsProps {
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }) => {
|
||||
const { t } = useTranslation();
|
||||
const { searchGroups } = useToolSections(filteredTools);
|
||||
|
||||
if (searchGroups.length === 0) {
|
||||
return <NoToolsFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack p="sm" gap="xs">
|
||||
{searchGroups.map(group => (
|
||||
<Box key={group.subcategory} w="100%">
|
||||
<SubcategoryHeader label={t(`toolPicker.subcategories.${group.subcategory}`, group.subcategory)} />
|
||||
<Stack gap="xs">
|
||||
{group.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
{/* global spacer to allow scrolling past last row in search mode */}
|
||||
<div aria-hidden style={{ height: 200 }} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import ToolPicker from './ToolPicker';
|
||||
import SearchResults from './SearchResults';
|
||||
import ToolRenderer from './ToolRenderer';
|
||||
import ToolSearch from './toolPicker/ToolSearch';
|
||||
import { useSidebarContext } from "../../contexts/SidebarContext";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
|
||||
@@ -23,12 +24,13 @@ export default function ToolPanel() {
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
handleBackToTools
|
||||
} = useToolPanelState();
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { selectedToolKey, handleToolSelect } = useToolSelection();
|
||||
const { setPreviewFile } = useWorkbenchState();
|
||||
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
|
||||
const { setPreviewFile } = useToolWorkflow();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -52,23 +54,39 @@ export default function ToolPanel() {
|
||||
}}
|
||||
>
|
||||
{/* Search Bar - Always visible at the top */}
|
||||
<div className="mb-4">
|
||||
<TextInput
|
||||
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--tool-panel-search-bg)',
|
||||
borderBottom: '1px solid var(--tool-panel-search-border-bottom)',
|
||||
padding: '0.75rem 1rem',
|
||||
}}
|
||||
>
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
autoComplete="off"
|
||||
size="sm"
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{leftPanelView === 'toolPicker' ? (
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
// Searching view (replaces both picker and content)
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 min-h-0">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={handleToolSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : leftPanelView === 'toolPicker' ? (
|
||||
// Tool Picker View
|
||||
<div className="flex-1 flex flex-col">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={handleToolSelect}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -76,10 +94,12 @@ export default function ToolPanel() {
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Tool content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey || ''}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
{selectedToolKey && (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,43 +1,230 @@
|
||||
import React from "react";
|
||||
import { Box, Text, Stack, Button } from "@mantine/core";
|
||||
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
|
||||
import { Box, Text, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistry } from "../../types/tool";
|
||||
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
|
||||
import ToolButton from "./toolPicker/ToolButton";
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
import { useToolSections } from "../../hooks/useToolSections";
|
||||
import SubcategoryHeader from "./shared/SubcategoryHeader";
|
||||
import NoToolsFound from "./shared/NoToolsFound";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
/** Pre-filtered tools to display */
|
||||
filteredTools: [string, ToolRegistry[string]][];
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProps) => {
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
const renderToolButtons = (
|
||||
subcategory: any,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true
|
||||
) => (
|
||||
<Box key={subcategory.subcategory} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={subcategory.subcategory} />
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
|
||||
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
|
||||
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const quickHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const allHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const quickAccessRef = useRef<HTMLDivElement>(null);
|
||||
const allToolsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// On resize adjust headers height to offset height
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
if (quickHeaderRef.current) {
|
||||
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight);
|
||||
}
|
||||
if (allHeaderRef.current) {
|
||||
setAllHeaderHeight(allHeaderRef.current.offsetHeight);
|
||||
}
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||
|
||||
const quickSection = useMemo(
|
||||
() => visibleSections.find(s => (s as any).key === 'quick'),
|
||||
[visibleSections]
|
||||
);
|
||||
const allSection = useMemo(
|
||||
() => visibleSections.find(s => (s as any).key === 'all'),
|
||||
[visibleSections]
|
||||
);
|
||||
|
||||
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
const container = scrollableRef.current;
|
||||
const target = ref.current;
|
||||
if (container && target) {
|
||||
const stackedOffset = ref === allToolsRef
|
||||
? (quickHeaderHeight + allHeaderHeight)
|
||||
: quickHeaderHeight;
|
||||
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
|
||||
container.scrollTo({
|
||||
top: Math.max(0, top),
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build flat list by subcategory for search mode
|
||||
const { searchGroups } = useToolSections(isSearching ? filteredTools : []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack align="flex-start">
|
||||
{filteredTools.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("toolPicker.noToolsFound", "No tools found")}
|
||||
</Text>
|
||||
<Box
|
||||
h="100vh"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--bg-toolbar)"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={scrollableRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
minHeight: 0,
|
||||
height: "100%"
|
||||
}}
|
||||
className="tool-picker-scrollable"
|
||||
>
|
||||
{isSearching ? (
|
||||
<Stack p="sm" gap="xs">
|
||||
{searchGroups.length === 0 ? (
|
||||
<NoToolsFound />
|
||||
) : (
|
||||
searchGroups.map(group => renderToolButtons(group, selectedToolKey, onSelect))
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
filteredTools.map(([id, { icon, name }]) => (
|
||||
<Button
|
||||
key={id}
|
||||
data-testid={`tool-${id}`}
|
||||
variant={selectedToolKey === id ? "filled" : "subtle"}
|
||||
onClick={() => onSelect(id)}
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={icon}
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
<>
|
||||
{quickSection && (
|
||||
<>
|
||||
<div
|
||||
ref={quickHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
marginBottom: -1,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 700,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
onClick={() => scrollTo(quickAccessRef)}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))
|
||||
<span>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
|
||||
<span
|
||||
style={{
|
||||
background: "var(--tool-header-badge-bg)",
|
||||
color: "var(--tool-header-badge-text)",
|
||||
borderRadius: ".5rem",
|
||||
padding: "0.125rem 0.5rem",
|
||||
fontSize: ".75rem",
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Box ref={quickAccessRef} w="100%">
|
||||
<Stack p="sm" gap="xs">
|
||||
{quickSection?.subcategories.map(sc =>
|
||||
renderToolButtons(sc, selectedToolKey, onSelect, false)
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{allSection && (
|
||||
<>
|
||||
<div
|
||||
ref={allHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: quickSection ? quickHeaderHeight - 1: 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 700,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
onClick={() => scrollTo(allToolsRef)}
|
||||
>
|
||||
<span>{t("toolPicker.allTools", "ALL TOOLS")}</span>
|
||||
<span
|
||||
style={{
|
||||
background: "var(--tool-header-badge-bg)",
|
||||
color: "var(--tool-header-badge-text)",
|
||||
borderRadius: ".5rem",
|
||||
padding: "0.125rem 0.5rem",
|
||||
fontSize: ".75rem",
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Box ref={allToolsRef} w="100%">
|
||||
<Stack p="sm" gap="xs">
|
||||
{allSection?.subcategories.map(sc =>
|
||||
renderToolButtons(sc, selectedToolKey, onSelect, true)
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!quickSection && !allSection && <NoToolsFound />}
|
||||
|
||||
{/* bottom spacer to allow scrolling past the last row */}
|
||||
<div aria-hidden style={{ height: 200 }} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ const ToolRenderer = ({
|
||||
|
||||
// Wrap lazy-loaded component with Suspense
|
||||
return (
|
||||
<Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
|
||||
<Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
|
||||
<ToolComponent
|
||||
onPreviewFile={onPreviewFile}
|
||||
onComplete={onComplete}
|
||||
|
||||
@@ -4,6 +4,7 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
|
||||
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
|
||||
import { getConversionEndpoints } from "../../../data/toolsTaxonomy";
|
||||
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
|
||||
import { useFileContext } from "../../../contexts/FileContext";
|
||||
import { detectFileExtension } from "../../../utils/fileUtils";
|
||||
@@ -43,15 +44,7 @@ const ConvertSettings = ({
|
||||
const { setSelectedFiles } = useFileSelectionActions();
|
||||
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
|
||||
|
||||
const allEndpoints = useMemo(() => {
|
||||
const endpoints = new Set<string>();
|
||||
Object.values(EXTENSION_TO_ENDPOINT).forEach(toEndpoints => {
|
||||
Object.values(toEndpoints).forEach(endpoint => {
|
||||
endpoints.add(endpoint);
|
||||
});
|
||||
});
|
||||
return Array.from(endpoints);
|
||||
}, []);
|
||||
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
|
||||
|
||||
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NoToolsFound: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Text c="dimmed" size="sm" p="sm">
|
||||
{t("toolPicker.noToolsFound", "No tools found")}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoToolsFound;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SubcategoryHeaderProps {
|
||||
label: string;
|
||||
mt?: string | number;
|
||||
mb?: string | number;
|
||||
}
|
||||
|
||||
export const SubcategoryHeader: React.FC<SubcategoryHeaderProps> = ({ label, mt = '1rem', mb = '0.25rem' }) => (
|
||||
<div className="tool-subcategory-row" style={{ marginLeft: '1rem', marginRight: '1rem', marginTop: mt, marginBottom: mb }}>
|
||||
<div className="tool-subcategory-row-rule" />
|
||||
<span className="tool-subcategory-row-title">{label}</span>
|
||||
<div className="tool-subcategory-row-rule" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SubcategoryHeader;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||
import FitText from "../../shared/FitText";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: string;
|
||||
tool: ToolRegistryEntry;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
|
||||
const handleClick = (id: string) => {
|
||||
if (tool.link) {
|
||||
// Open external link in new tab
|
||||
window.open(tool.link, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
// Normal tool selection
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={tool.description} position="right" arrow={true} delay={500}>
|
||||
<Button
|
||||
variant={isSelected ? "filled" : "subtle"}
|
||||
onClick={()=> handleClick(id)}
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
||||
>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%' }}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolButton;
|
||||
@@ -0,0 +1,79 @@
|
||||
.tool-picker-scrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mantine-color-gray-4) transparent;
|
||||
}
|
||||
|
||||
.tool-picker-scrollable::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
.tool-picker-scrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tool-picker-scrollable::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
.tool-picker-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.tool-subcategory-title {
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--tool-subcategory-text-color);
|
||||
/* Align the text with tool labels to account for icon gutter */
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
/* New row-style subcategory header with rule */
|
||||
.tool-subcategory-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-subcategory-row-title {
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--tool-subcategory-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tool-subcategory-row-rule {
|
||||
height: 1px;
|
||||
background-color: var(--tool-subcategory-rule-color);
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Compact tool buttons */
|
||||
.tool-button {
|
||||
font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */
|
||||
padding-top: 0.375rem;
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.tool-button .mantine-Button-label {
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.tool-button-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Stack, Button, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||
import { TextInput } from "../../shared/TextInput";
|
||||
import './ToolPicker.css';
|
||||
|
||||
interface ToolSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
|
||||
onToolSelect?: (toolId: string) => void;
|
||||
mode: 'filter' | 'dropdown';
|
||||
selectedToolKey?: string | null;
|
||||
}
|
||||
|
||||
const ToolSearch = ({
|
||||
value,
|
||||
onChange,
|
||||
toolRegistry,
|
||||
onToolSelect,
|
||||
mode = 'filter',
|
||||
selectedToolKey
|
||||
}: ToolSearchProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!value.trim()) return [];
|
||||
return Object.entries(toolRegistry)
|
||||
.filter(([id, tool]) => {
|
||||
if (mode === 'dropdown' && id === selectedToolKey) return false;
|
||||
return tool.name.toLowerCase().includes(value.toLowerCase()) ||
|
||||
tool.description.toLowerCase().includes(value.toLowerCase());
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map(([id, tool]) => ({ id, tool }));
|
||||
}, [value, toolRegistry, mode, selectedToolKey]);
|
||||
|
||||
const handleSearchChange = (searchValue: string) => {
|
||||
onChange(searchValue);
|
||||
if (mode === 'dropdown') {
|
||||
setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const searchInput = (
|
||||
<div className="search-input-container">
|
||||
<TextInput
|
||||
ref={searchRef}
|
||||
value={value}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
icon={<span className="material-symbols-rounded">search</span>}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (mode === 'filter') {
|
||||
return searchInput;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={searchRef} style={{ position: 'relative' }}>
|
||||
{searchInput}
|
||||
{dropdownOpen && filteredTools.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: '8px',
|
||||
marginTop: '4px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" style={{ padding: '8px' }}>
|
||||
{filteredTools.map(({ id, tool }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant="subtle"
|
||||
onClick={() => onToolSelect && onToolSelect(id)}
|
||||
leftSection={
|
||||
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
|
||||
{tool.icon}
|
||||
</div>
|
||||
}
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
color: 'var(--tools-text-and-icon-color)',
|
||||
padding: '8px 12px'
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ fontWeight: 500 }}>{tool.name}</div>
|
||||
<Text size="xs" c="dimmed" style={{ marginTop: '2px' }}>
|
||||
{tool.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolSearch;
|
||||
Reference in New Issue
Block a user