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:
EthanHealy01
2025-08-19 13:31:09 +01:00
committed by GitHub
parent c1b7911518
commit 8f32082145
40 changed files with 2617 additions and 451 deletions
+3 -3
View File
@@ -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;
+74 -158
View File
@@ -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>
);
});
+41 -20
View File
@@ -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;
@@ -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;
+36 -16
View File
@@ -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>
)}
+214 -27
View File
@@ -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;
+17 -26
View File
@@ -5,9 +5,8 @@
import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
import { useToolManagement } from '../hooks/useToolManagement';
import { useToolUrlRouting } from '../hooks/useToolUrlRouting';
import { Tool } from '../types/tool';
import { PageEditorFunctions } from '../types/pageEditor';
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
// State interface
interface ToolWorkflowState {
@@ -70,9 +69,9 @@ function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowActio
interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: string | null;
selectedTool: Tool | null;
selectedTool: ToolRegistryEntry | null;
toolRegistry: any; // From useToolManagement
// UI Actions
setSidebarsVisible: (visible: boolean) => void;
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
@@ -91,7 +90,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
handleReaderToggle: () => void;
// Computed values
filteredTools: [string, any][]; // Filtered by search
filteredTools: [string, ToolRegistryEntry][]; // Filtered by search
isPanelVisible: boolean;
}
@@ -143,11 +142,22 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: string) => {
// Special-case: if tool is a dedicated reader tool, enter reader mode and do not go to toolContent
if (toolId === 'read' || toolId === 'view-pdf') {
setReaderMode(true);
setLeftPanelView('toolPicker');
clearToolSelection();
setSearchQuery('');
return;
}
selectTool(toolId);
onViewChange?.('fileEditor');
setLeftPanelView('toolContent');
setReaderMode(false);
}, [selectTool, onViewChange, setLeftPanelView, setReaderMode]);
// Clear search so the tool content becomes visible immediately
setSearchQuery('');
}, [selectTool, onViewChange, setLeftPanelView, setReaderMode, setSearchQuery, clearToolSelection]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
@@ -159,20 +169,6 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
setReaderMode(true);
}, [setReaderMode]);
// URL routing functionality
const { getToolUrlSlug, getToolKeyFromSlug } = useToolUrlRouting({
selectedToolKey,
toolRegistry,
selectTool,
clearToolSelection,
// During initial load, we want the full UI side-effects (like before):
onInitSelect: handleToolSelect,
// For back/forward nav, keep it lightweight like before (selection only):
onPopStateSelect: selectTool,
// If your app serves under a subpath, provide basePath here (e.g., '/app')
// basePath: ''
});
// Filter tools based on search query
const filteredTools = useMemo(() => {
if (!toolRegistry) return [];
@@ -228,9 +224,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
}
return context;
}
// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly)
export const useToolSelection = useToolWorkflow;
export const useToolPanelState = useToolWorkflow;
export const useWorkbenchState = useToolWorkflow;
}
+102
View File
@@ -0,0 +1,102 @@
import { type TFunction } from 'i18next';
import React from 'react';
export enum SubcategoryId {
SIGNING = 'signing',
DOCUMENT_SECURITY = 'documentSecurity',
VERIFICATION = 'verification',
DOCUMENT_REVIEW = 'documentReview',
PAGE_FORMATTING = 'pageFormatting',
EXTRACTION = 'extraction',
REMOVAL = 'removal',
AUTOMATION = 'automation',
GENERAL = 'general',
ADVANCED_FORMATTING = 'advancedFormatting',
DEVELOPER_TOOLS = 'developerTools'
}
export enum ToolCategory {
STANDARD_TOOLS = 'Standard Tools',
ADVANCED_TOOLS = 'Advanced Tools',
RECOMMENDED_TOOLS = 'Recommended Tools'
}
export type ToolRegistryEntry = {
icon: React.ReactNode;
name: string;
component: React.ComponentType<any> | null;
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
description: string;
category: ToolCategory;
subcategory: SubcategoryId;
maxFiles?: number;
supportedFormats?: string[];
endpoints?: string[];
link?: string;
type?: string;
}
export type ToolRegistry = Record<string, ToolRegistryEntry>;
export const SUBCATEGORY_ORDER: SubcategoryId[] = [
SubcategoryId.SIGNING,
SubcategoryId.DOCUMENT_SECURITY,
SubcategoryId.VERIFICATION,
SubcategoryId.DOCUMENT_REVIEW,
SubcategoryId.PAGE_FORMATTING,
SubcategoryId.EXTRACTION,
SubcategoryId.REMOVAL,
SubcategoryId.AUTOMATION,
SubcategoryId.GENERAL,
SubcategoryId.ADVANCED_FORMATTING,
SubcategoryId.DEVELOPER_TOOLS,
];
export const SUBCATEGORY_COLOR_MAP: Record<SubcategoryId, string> = {
[SubcategoryId.SIGNING]: '#FF7892',
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
[SubcategoryId.VERIFICATION]: '#1BB1D4',
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
[SubcategoryId.EXTRACTION]: '#1BB1D4',
[SubcategoryId.REMOVAL]: '#7882FF',
[SubcategoryId.AUTOMATION]: '#69DC95',
[SubcategoryId.GENERAL]: '#69DC95',
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
};
export const getSubcategoryColor = (subcategory: SubcategoryId): string => SUBCATEGORY_COLOR_MAP[subcategory] || '#7882FF';
export const getSubcategoryLabel = (t: TFunction, id: SubcategoryId): string => t(`toolPicker.subcategories.${id}`, id);
export const getAllEndpoints = (registry: ToolRegistry): string[] => {
const lists: string[][] = [];
Object.values(registry).forEach(entry => {
if (entry.endpoints && entry.endpoints.length > 0) {
lists.push(entry.endpoints);
}
});
return Array.from(new Set(lists.flat()));
};
export const getConversionEndpoints = (extensionToEndpoint: Record<string, Record<string, string>>): string[] => {
const endpoints = new Set<string>();
Object.values(extensionToEndpoint).forEach(toEndpoints => {
Object.values(toEndpoints).forEach(endpoint => {
endpoints.add(endpoint);
});
});
return Array.from(endpoints);
};
export const getAllApplicationEndpoints = (
registry: ToolRegistry,
extensionToEndpoint?: Record<string, Record<string, string>>
): string[] => {
const toolEp = getAllEndpoints(registry);
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
return Array.from(new Set([...toolEp, ...convEp]));
};
@@ -0,0 +1,606 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import SplitPdfPanel from "../tools/Split";
import CompressPdfPanel from "../tools/Compress";
import OCRPanel from '../tools/OCR';
import ConvertPanel from '../tools/Convert';
import Sanitize from '../tools/Sanitize';
import AddPassword from '../tools/AddPassword';
import ChangePermissions from '../tools/ChangePermissions';
import RemovePassword from '../tools/RemovePassword';
import { SubcategoryId, ToolCategory, ToolRegistry } from './toolsTaxonomy';
import AddWatermark from '../tools/AddWatermark';
// Hook to get the translated tool registry
export function useFlatToolRegistry(): ToolRegistry {
const { t } = useTranslation();
return {
// Signing
"certSign": {
icon: <span className="material-symbols-rounded">workspace_premium</span>,
name: t("home.certSign.title", "Sign with Certificate"),
component: null,
view: "sign",
description: t("home.certSign.desc", "Signs a PDF with a Certificate/Key (PEM/P12)"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.SIGNING
},
"sign": {
icon: <span className="material-symbols-rounded">signature</span>,
name: t("home.sign.title", "Sign"),
component: null,
view: "sign",
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.SIGNING
},
// Document Security
"addPassword": {
icon: <span className="material-symbols-rounded">password</span>,
name: t("home.addPassword.title", "Add Password"),
component: AddPassword,
view: "security",
description: t("home.addPassword.desc", "Add password protection and restrictions to PDF files"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-password"]
},
"add-watermark": {
icon: <span className="material-symbols-rounded">branding_watermark</span>,
name: t("home.watermark.title", "Add Watermark"),
component: AddWatermark,
view: "format",
maxFiles: -1,
description: t("home.watermark.desc", "Add a custom watermark to your PDF document."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
endpoints: ["add-watermark"]
},
"add-stamp": {
icon: <span className="material-symbols-rounded">approval</span>,
name: t("home.AddStampRequest.title", "Add Stamp to PDF"),
component: null,
view: "format",
description: t("home.AddStampRequest.desc", "Add text or add image stamps at set locations"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
},
"sanitize": {
icon: <span className="material-symbols-rounded">cleaning_services</span>,
name: t("home.sanitize.title", "Sanitize"),
component: Sanitize,
view: "security",
maxFiles: -1,
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"),
endpoints: ["sanitize-pdf"]
},
"flatten": {
icon: <span className="material-symbols-rounded">layers_clear</span>,
name: t("home.flatten.title", "Flatten"),
component: null,
view: "format",
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
},
"unlock-pdf-forms": {
icon: <span className="material-symbols-rounded">preview_off</span>,
name: t("home.unlockPDFForms.title", "Unlock PDF Forms"),
component: null,
view: "security",
description: t("home.unlockPDFForms.desc", "Remove read-only property of form fields in a PDF document."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
},
"manage-certificates": {
icon: <span className="material-symbols-rounded">license</span>,
name: t("home.manageCertificates.title", "Manage Certificates"),
component: null,
view: "security",
description: t("home.manageCertificates.desc", "Import, export, or delete digital certificate files used for signing PDFs."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
},
"change-permissions": {
icon: <span className="material-symbols-rounded">lock</span>,
name: t("home.changePermissions.title", "Change Permissions"),
component: ChangePermissions,
view: "security",
description: t("home.changePermissions.desc", "Change document restrictions and permissions"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-password"]
},
// Verification
"get-all-info-on-pdf": {
icon: <span className="material-symbols-rounded">fact_check</span>,
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
component: null,
view: "extract",
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.VERIFICATION
},
"validate-pdf-signature": {
icon: <span className="material-symbols-rounded">verified</span>,
name: t("home.validateSignature.title", "Validate PDF Signature"),
component: null,
view: "security",
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.VERIFICATION
},
// Document Review
"read": {
icon: <span className="material-symbols-rounded">article</span>,
name: t("home.read.title", "Read"),
component: null,
view: "view",
description: t("home.read.desc", "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_REVIEW
},
"change-metadata": {
icon: <span className="material-symbols-rounded">assignment</span>,
name: t("home.changeMetadata.title", "Change Metadata"),
component: null,
view: "format",
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_REVIEW
},
// Page Formatting
"cropPdf": {
icon: <span className="material-symbols-rounded">crop</span>,
name: t("home.crop.title", "Crop PDF"),
component: null,
view: "format",
description: t("home.crop.desc", "Crop a PDF to reduce its size (maintains text!)"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
},
"rotate": {
icon: <span className="material-symbols-rounded">rotate_right</span>,
name: t("home.rotate.title", "Rotate"),
component: null,
view: "format",
description: t("home.rotate.desc", "Easily rotate your PDFs."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
},
"splitPdf": {
icon: <span className="material-symbols-rounded">content_cut</span>,
name: t("home.split.title", "Split"),
component: SplitPdfPanel,
view: "split",
description: t("home.split.desc", "Split PDFs into multiple documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
},
"reorganize-pages": {
icon: <span className="material-symbols-rounded">move_down</span>,
name: t("home.reorganizePages.title", "Reorganize Pages"),
component: null,
view: "pageEditor",
description: t("home.reorganizePages.desc", "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
},
"adjust-page-size-scale": {
icon: <span className="material-symbols-rounded">crop_free</span>,
name: t("home.scalePages.title", "Adjust page size/scale"),
component: null,
view: "format",
description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
},
"add-page-numbers": {
icon: <span className="material-symbols-rounded">123</span>,
name: t("home.add-page-numbers.title", "Add Page Numbers"),
component: null,
view: "format",
description: t("home.add-page-numbers.desc", "Add Page numbers throughout a document in a set location"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
},
"multi-page-layout": {
icon: <span className="material-symbols-rounded">dashboard</span>,
name: t("home.pageLayout.title", "Multi-Page Layout"),
component: null,
view: "format",
description: t("home.pageLayout.desc", "Merge multiple pages of a PDF document into a single page"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
},
"single-large-page": {
icon: <span className="material-symbols-rounded">looks_one</span>,
name: t("home.PdfToSinglePage.title", "PDF to Single Large Page"),
component: null,
view: "format",
description: t("home.PdfToSinglePage.desc", "Merges all PDF pages into one large single page"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
},
"add-attachments": {
icon: <span className="material-symbols-rounded">attachment</span>,
name: t("home.attachments.title", "Add Attachments"),
component: null,
view: "format",
description: t("home.attachments.desc", "Add or remove embedded files (attachments) to/from a PDF"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING,
},
// Extraction
"extract-pages": {
icon: <span className="material-symbols-rounded">upload</span>,
name: t("home.extractPage.title", "Extract Pages"),
component: null,
view: "extract",
description: t("home.extractPage.desc", "Extract specific pages from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.EXTRACTION
},
"extract-images": {
icon: <span className="material-symbols-rounded">filter</span>,
name: t("home.extractImages.title", "Extract Images"),
component: null,
view: "extract",
description: t("home.extractImages.desc", "Extract images from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.EXTRACTION
},
// Removal
"remove": {
icon: <span className="material-symbols-rounded">delete</span>,
name: t("home.removePages.title", "Remove Pages"),
component: null,
view: "remove",
description: t("home.removePages.desc", "Remove specific pages from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
},
"remove-blank-pages": {
icon: <span className="material-symbols-rounded">scan_delete</span>,
name: t("home.removeBlanks.title", "Remove Blank Pages"),
component: null,
view: "remove",
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
},
"remove-annotations": {
icon: <span className="material-symbols-rounded">thread_unread</span>,
name: t("home.removeAnnotations.title", "Remove Annotations"),
component: null,
view: "remove",
description: t("home.removeAnnotations.desc", "Remove annotations and comments from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
},
"remove-image": {
icon: <span className="material-symbols-rounded">remove_selection</span>,
name: t("home.removeImagePdf.title", "Remove Image"),
component: null,
view: "format",
description: t("home.removeImagePdf.desc", "Remove images from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
},
"remove-password": {
icon: <span className="material-symbols-rounded">lock_open_right</span>,
name: t("home.removePassword.title", "Remove Password"),
component: RemovePassword,
view: "security",
description: t("home.removePassword.desc", "Remove password protection from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL,
endpoints: ["remove-password"],
maxFiles: -1,
},
"remove-certificate-sign": {
icon: <span className="material-symbols-rounded">remove_moderator</span>,
name: t("home.removeCertSign.title", "Remove Certificate Signatures"),
component: null,
view: "security",
description: t("home.removeCertSign.desc", "Remove digital signatures from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
},
// Automation
"automate": {
icon: <span className="material-symbols-rounded">automation</span>,
name: t("home.automate.title", "Automate"),
component: null,
view: "format",
description: t("home.automate.desc", "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
},
"auto-rename-pdf-file": {
icon: <span className="material-symbols-rounded">match_word</span>,
name: t("home.auto-rename.title", "Auto Rename PDF File"),
component: null,
view: "format",
description: t("home.auto-rename.desc", "Automatically rename PDF files based on their content"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
},
"auto-split-pages": {
icon: <span className="material-symbols-rounded">split_scene_right</span>,
name: t("home.autoSplitPDF.title", "Auto Split Pages"),
component: null,
view: "format",
description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
},
"auto-split-by-size-count": {
icon: <span className="material-symbols-rounded">content_cut</span>,
name: t("home.autoSizeSplitPDF.title", "Auto Split by Size/Count"),
component: null,
view: "format",
description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
},
// Advanced Formatting
"adjust-colors-contrast": {
icon: <span className="material-symbols-rounded">palette</span>,
name: t("home.adjust-contrast.title", "Adjust Colors/Contrast"),
component: null,
view: "format",
description: t("home.adjust-contrast.desc", "Adjust colors and contrast of PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
},
"repair": {
icon: <span className="material-symbols-rounded">build</span>,
name: t("home.repair.title", "Repair"),
component: null,
view: "format",
description: t("home.repair.desc", "Repair corrupted or damaged PDF files"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
},
"detect-split-scanned-photos": {
icon: <span className="material-symbols-rounded">scanner</span>,
name: t("home.ScannerImageSplit.title", "Detect & Split Scanned Photos"),
component: null,
view: "format",
description: t("home.ScannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
},
"overlay-pdfs": {
icon: <span className="material-symbols-rounded">layers</span>,
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
component: null,
view: "format",
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
},
"replace-and-invert-color": {
icon: <span className="material-symbols-rounded">format_color_fill</span>,
name: t("home.replaceColorPdf.title", "Replace & Invert Color"),
component: null,
view: "format",
description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
},
"add-image": {
icon: <span className="material-symbols-rounded">image</span>,
name: t("home.addImage.title", "Add Image"),
component: null,
view: "format",
description: t("home.addImage.desc", "Add images to PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
},
"edit-table-of-contents": {
icon: <span className="material-symbols-rounded">bookmark_add</span>,
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
component: null,
view: "format",
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
},
"scanner-effect": {
icon: <span className="material-symbols-rounded">scanner</span>,
name: t("home.fakeScan.title", "Scanner Effect"),
component: null,
view: "format",
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
},
// Developer Tools
"show-javascript": {
icon: <span className="material-symbols-rounded">javascript</span>,
name: t("home.showJS.title", "Show JavaScript"),
component: null,
view: "extract",
description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS
},
"dev-api": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
name: t("home.devApi.title", "API"),
component: null,
view: "external",
description: t("home.devApi.desc", "Link to API documentation"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html"
},
"dev-folder-scanning": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
name: t("home.devFolderScanning.title", "Automated Folder Scanning"),
component: null,
view: "external",
description: t("home.devFolderScanning.desc", "Link to automated folder scanning guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/"
},
"dev-sso-guide": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
name: t("home.devSsoGuide.title", "SSO Guide"),
component: null,
view: "external",
description: t("home.devSsoGuide.desc", "Link to SSO guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
},
"dev-airgapped": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
name: t("home.devAirgapped.title", "Air-gapped Setup"),
component: null,
view: "external",
description: t("home.devAirgapped.desc", "Link to air-gapped setup guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Pro/#activation"
},
// Recommended Tools
"compare": {
icon: <span className="material-symbols-rounded">compare</span>,
name: t("home.compare.title", "Compare"),
component: null,
view: "format",
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL
},
"compressPdfs": {
icon: <span className="material-symbols-rounded">zoom_in_map</span>,
name: t("home.compressPdfs.title", "Compress"),
component: CompressPdfPanel,
view: "compress",
description: t("home.compressPdfs.desc", "Compress PDFs to reduce their file size."),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1
},
"convert": {
icon: <span className="material-symbols-rounded">sync_alt</span>,
name: t("home.fileToPDF.title", "Convert"),
component: ConvertPanel,
view: "convert",
description: t("home.fileToPDF.desc", "Convert files to and from PDF format"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: [
"pdf-to-img",
"img-to-pdf",
"pdf-to-word",
"pdf-to-presentation",
"pdf-to-text",
"pdf-to-html",
"pdf-to-xml",
"html-to-pdf",
"markdown-to-pdf",
"file-to-pdf",
"pdf-to-csv",
"pdf-to-markdown",
"pdf-to-pdfa",
"eml-to-pdf"
],
supportedFormats: [
// Microsoft Office
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
// OpenDocument
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
// Text formats
"txt", "text", "xml", "rtf", "html", "lwp", "md",
// Images
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
// StarOffice
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
]
},
"mergePdfs": {
icon: <span className="material-symbols-rounded">library_add</span>,
name: t("home.merge.title", "Merge"),
component: null,
view: "merge",
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1
},
"multi-tool": {
icon: <span className="material-symbols-rounded">dashboard_customize</span>,
name: t("home.multiTool.title", "Multi-Tool"),
component: null,
view: "pageEditor",
description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1
},
"ocr": {
icon: <span className="material-symbols-rounded">quick_reference_all</span>,
name: t("home.ocr.title", "OCR"),
component: OCRPanel,
view: "convert",
description: t("home.ocr.desc", "Extract text from scanned PDFs using Optical Character Recognition"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1
},
"redact": {
icon: <span className="material-symbols-rounded">visibility_off</span>,
name: t("home.redact.title", "Redact"),
component: null,
view: "redact",
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL
},
};
}
+2 -1
View File
@@ -4,4 +4,5 @@ declare module "../tools/Merge";
declare module "../components/PageEditor";
declare module "../components/Viewer";
declare module "*.js";
declare module '*.module.css';
declare module '*.module.css';
declare module 'pdfjs-dist';
@@ -34,7 +34,7 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
const page = await pdf.getPage(i);
const annotations = await page.getAnnotations({ intent: 'display' });
annotations.forEach(annotation => {
annotations.forEach((annotation: any) => {
if (annotation.subtype === 'Widget' && annotation.fieldType === 'Sig') {
foundSignature = true;
}
+7 -1
View File
@@ -18,7 +18,13 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
if (stored && ['light', 'dark', 'rainbow'].includes(stored)) {
return stored as ThemeMode;
}
return initialTheme;
try {
// Fallback to OS preference if available
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : initialTheme;
} catch {
return initialTheme;
}
});
// Track rapid toggles for easter egg
+30 -146
View File
@@ -1,137 +1,14 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import ApiIcon from "@mui/icons-material/Api";
import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
import LockIcon from "@mui/icons-material/Lock";
import BrandingWatermarkIcon from "@mui/icons-material/BrandingWatermark";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, ToolRegistry } from "../types/tool";
// Add entry here with maxFiles, endpoints, and lazy component
const toolDefinitions: Record<string, ToolDefinition> = {
split: {
id: "split",
icon: <ContentCutIcon />,
component: React.lazy(() => import("../tools/Split")),
maxFiles: 1,
category: "manipulation",
description: "Split PDF files into smaller parts",
endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"]
},
compress: {
id: "compress",
icon: <ZoomInMapIcon />,
component: React.lazy(() => import("../tools/Compress")),
maxFiles: -1,
category: "optimization",
description: "Reduce PDF file size",
endpoints: ["compress-pdf"]
},
convert: {
id: "convert",
icon: <SwapHorizIcon />,
component: React.lazy(() => import("../tools/Convert")),
maxFiles: -1,
category: "manipulation",
description: "Change to and from PDF and other formats",
endpoints: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"],
supportedFormats: [
// Microsoft Office
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
// OpenDocument
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
// Text formats
"txt", "text", "xml", "rtf", "html", "lwp", "md",
// Images
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
// StarOffice
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
]
},
swagger: {
id: "swagger",
icon: <ApiIcon />,
component: React.lazy(() => import("../tools/SwaggerUI")),
maxFiles: 0,
category: "utility",
description: "Open API documentation",
endpoints: ["swagger-ui"]
},
ocr: {
id: "ocr",
icon: <span className="material-symbols-rounded font-size-20">
quick_reference_all
</span>,
component: React.lazy(() => import("../tools/OCR")),
maxFiles: -1,
category: "utility",
description: "Extract text from images using OCR",
endpoints: ["ocr-pdf"]
},
sanitize: {
id: "sanitize",
icon: <CleaningServicesIcon />,
component: React.lazy(() => import("../tools/Sanitize")),
maxFiles: -1,
category: "security",
description: "Remove potentially harmful elements from PDF files",
endpoints: ["sanitize-pdf"]
},
addPassword: {
id: "addPassword",
icon: <LockIcon />,
component: React.lazy(() => import("../tools/AddPassword")),
maxFiles: -1,
category: "security",
description: "Add password protection and restrictions to PDF files",
endpoints: ["add-password"]
},
changePermissions: {
id: "changePermissions",
icon: <LockIcon />,
component: React.lazy(() => import("../tools/ChangePermissions")),
maxFiles: -1,
category: "security",
description: "Change document restrictions and permissions",
endpoints: ["add-password"]
},
watermark: {
id: "watermark",
icon: <BrandingWatermarkIcon />,
component: React.lazy(() => import("../tools/AddWatermark")),
maxFiles: -1,
category: "security",
description: "Add text or image watermarks to PDF files",
endpoints: ["add-watermark"]
},
removePassword: {
id: "removePassword",
icon: <LockOpenIcon />,
component: React.lazy(() => import("../tools/RemovePassword")),
maxFiles: -1,
category: "security",
description: "Remove password protection from PDF files",
endpoints: ["remove-password"]
},
};
interface ToolManagementResult {
selectedToolKey: string | null;
selectedTool: Tool | null;
selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: string[];
toolRegistry: ToolRegistry;
toolRegistry: Record<string, ToolRegistryEntry>;
selectTool: (toolKey: string) => void;
clearToolSelection: () => void;
setToolSelectedFileIds: (fileIds: string[]) => void;
@@ -143,33 +20,41 @@ export const useToolManagement = (): ToolManagementResult => {
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
const allEndpoints = Array.from(new Set(
Object.values(toolDefinitions).flatMap(tool => tool.endpoints || [])
));
// Build endpoints list from registry entries with fallback to legacy mapping
const baseRegistry = useFlatToolRegistry();
const registryDerivedEndpoints = useMemo(() => {
const endpointsByTool: Record<string, string[]> = {};
Object.entries(baseRegistry).forEach(([key, entry]) => {
if (entry.endpoints && entry.endpoints.length > 0) {
endpointsByTool[key] = entry.endpoints;
}
});
return endpointsByTool;
}, [baseRegistry]);
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
if (endpointsLoading) return true;
const tool = toolDefinitions[toolKey];
if (!tool?.endpoints) return true;
return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus]);
const endpoints = baseRegistry[toolKey]?.endpoints || [];
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const toolRegistry: ToolRegistry = useMemo(() => {
const availableTools: ToolRegistry = {};
Object.keys(toolDefinitions).forEach(toolKey => {
const toolRegistry: Record<string, ToolRegistryEntry> = useMemo(() => {
const availableToolRegistry: Record<string, ToolRegistryEntry> = {};
Object.keys(baseRegistry).forEach(toolKey => {
if (isToolAvailable(toolKey)) {
const toolDef = toolDefinitions[toolKey];
availableTools[toolKey] = {
...toolDef,
name: t(`${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)),
title: t(`${toolKey}.title`, toolDef.description || toolKey),
description: t(`${toolKey}.desc`, toolDef.description || `${toolKey} tool`)
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
availableToolRegistry[toolKey] = {
...baseTool,
name: t(baseTool.name),
description: t(baseTool.description)
};
}
});
return availableTools;
}, [t, isToolAvailable]);
return availableToolRegistry;
}, [isToolAvailable, t, baseRegistry]);
useEffect(() => {
if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) {
@@ -197,7 +82,6 @@ export const useToolManagement = (): ToolManagementResult => {
selectedTool,
toolSelectedFileIds,
toolRegistry,
selectTool,
clearToolSelection,
setToolSelectedFileIds,
+88
View File
@@ -0,0 +1,88 @@
import { useMemo } from 'react';
import { SUBCATEGORY_ORDER, ToolCategory, ToolRegistryEntry } from '../data/toolsTaxonomy';
import { useTranslation } from 'react-i18next';
type GroupedTools = {
[category: string]: {
[subcategory: string]: Array<{ id: string; tool: ToolRegistryEntry }>;
};
};
export function useToolSections(filteredTools: [string, ToolRegistryEntry][]) {
const { t } = useTranslation();
const groupedTools = useMemo(() => {
const grouped: GroupedTools = {};
filteredTools.forEach(([id, tool]) => {
const category = tool.category;
const subcategory = tool.subcategory;
if (!grouped[category]) grouped[category] = {};
if (!grouped[category][subcategory]) grouped[category][subcategory] = [];
grouped[category][subcategory].push({ id, tool });
});
return grouped;
}, [filteredTools]);
const sections = useMemo(() => {
const getOrderIndex = (name: string) => {
const idx = SUBCATEGORY_ORDER.indexOf(name as any);
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
};
const quick: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const all: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
Object.entries(groupedTools).forEach(([origCat, subs]) => {
const upperCat = origCat.toUpperCase();
Object.entries(subs).forEach(([sub, tools]) => {
if (!all[sub]) all[sub] = [];
all[sub].push(...tools);
});
if (upperCat === ToolCategory.RECOMMENDED_TOOLS.toUpperCase()) {
Object.entries(subs).forEach(([sub, tools]) => {
if (!quick[sub]) quick[sub] = [];
quick[sub].push(...tools);
});
}
});
const sortSubs = (obj: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>>) =>
Object.entries(obj)
.sort(([a], [b]) => {
const ai = getOrderIndex(a);
const bi = getOrderIndex(b);
if (ai !== bi) return ai - bi;
return a.localeCompare(b);
})
.map(([subcategory, tools]) => ({ subcategory, tools }));
const built = [
{ key: 'quick', title: t('toolPicker.quickAccess', 'QUICK ACCESS'), subcategories: sortSubs(quick) },
{ key: 'all', title: t('toolPicker.allTools', 'ALL TOOLS'), subcategories: sortSubs(all) }
];
return built.filter(section => section.subcategories.some(sc => sc.tools.length > 0));
}, [groupedTools]);
const searchGroups = useMemo(() => {
const subMap: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const seen = new Set<string>();
filteredTools.forEach(([id, tool]) => {
if (seen.has(id)) return;
seen.add(id);
const sub = tool.subcategory;
if (!subMap[sub]) subMap[sub] = [];
subMap[sub].push({ id, tool });
});
return Object.entries(subMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([subcategory, tools]) => ({ subcategory, tools }));
}, [filteredTools]);
return { sections, searchGroups };
}
+16 -7
View File
@@ -2,11 +2,22 @@ import '@mantine/core/styles.css';
import './index.css'; // Import Tailwind CSS
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core';
import { ColorSchemeScript } from '@mantine/core';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './i18n'; // Initialize i18next
// Compute initial color scheme
function getInitialScheme(): 'light' | 'dark' {
const stored = localStorage.getItem('stirling-theme');
if (stored === 'light' || stored === 'dark') return stored;
try {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
} catch {
return 'light';
}
}
const container = document.getElementById('root');
if (!container) {
@@ -15,12 +26,10 @@ if (!container) {
const root = ReactDOM.createRoot(container); // Finds the root DOM element
root.render(
<React.StrictMode>
<ColorSchemeScript />
<MantineProvider defaultColorScheme="auto">
<BrowserRouter>
<App />
</BrowserRouter>
</MantineProvider>
<ColorSchemeScript defaultColorScheme={getInitialScheme()} />
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+7 -7
View File
@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFileContext } from "../contexts/FileContext";
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext";
import { ToolWorkflowProvider, useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { Group } from "@mantine/core";
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
import { useDocumentMeta } from "../hooks/useDocumentMeta";
@@ -24,24 +24,24 @@ function HomePageContent() {
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
const { selectedTool } = useToolSelection();
const { selectedTool, selectedToolKey } = useToolWorkflow();
const baseUrl = getBaseUrl();
// Update document meta when tool changes
useDocumentMeta({
title: selectedTool?.title ? `${selectedTool.title} - Stirling PDF` : 'Stirling PDF',
title: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF',
description: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: selectedTool?.title ? `${selectedTool.title} - Stirling PDF` : 'Stirling PDF',
ogTitle: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF',
ogDescription: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: selectedTool ? `${baseUrl}/og_images/${selectedTool.id}.png` : `${baseUrl}/og_images/home.png`,
ogImage: selectedToolKey ? `${baseUrl}/og_images/${selectedToolKey}.png` : `${baseUrl}/og_images/home.png`,
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl
});
// Update file selection context when tool changes
useEffect(() => {
if (selectedTool) {
setMaxFiles(selectedTool.maxFiles);
setMaxFiles(selectedTool.maxFiles ?? -1);
setIsToolMode(true);
} else {
setMaxFiles(-1);
@@ -76,4 +76,4 @@ export default function HomePage() {
</ToolWorkflowProvider>
</FileSelectionProvider>
);
}
}
+44 -3
View File
@@ -81,7 +81,7 @@
--text-secondary: #4b5563;
--text-muted: #6b7280;
--border-subtle: #e5e7eb;
--border-default: #d1d5db;
--border-default: #E2E8F0;
--border-strong: #9ca3af;
--hover-bg: #f9fafb;
--active-bg: #f3f4f6;
@@ -117,11 +117,31 @@
--icon-inactive-bg: #9CA3AF;
--icon-inactive-color: #FFFFFF;
/* New theme colors for text and icons */
--tools-text-and-icon-color: #374151;
/* Tool picker sticky header variables (light mode) */
--tool-header-bg: #DBEFFF;
--tool-header-border: #BEE2FF;
--tool-header-text: #1E88E5;
--tool-header-badge-bg: #C0DDFF;
--tool-header-badge-text: #004E99;
/* Subcategory title styling (light mode) */
--tool-subcategory-text-color: #9CA3AF; /* lighter text */
--tool-subcategory-rule-color: #E5E7EB; /* doubly lighter rule line */
--accent-interactive: #4A90E2;
--text-instruction: #4A90E2;
--text-brand: var(--color-gray-700);
--text-brand-accent: #DC2626;
/* Placeholder text colors */
--search-text-and-icon-color: #6B7382;
/* Tool panel search bar background colors */
--tool-panel-search-bg: #EFF1F4;
--tool-panel-search-border-bottom: #EFF1F4;
/* container */
--landing-paper-bg: var(--bg-surface);
--landing-inner-paper-bg: #EEF8FF;
@@ -177,7 +197,7 @@
--bg-raised: #1F2329;
--bg-muted: #1F2329;
--bg-background: #2A2F36;
--bg-toolbar: #272A2E;
--bg-toolbar: #1F2329;
--bg-file-manager: #1F2329;
--bg-file-list: #2A2F36;
--btn-open-file: #0A8BFF;
@@ -185,7 +205,7 @@
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-subtle: #2A2F36;
--border-default: #374151;
--border-default: #3A4047;
--border-strong: #4b5563;
--hover-bg: #374151;
--active-bg: #4b5563;
@@ -251,6 +271,27 @@
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
--tools-text-and-icon-color: #D0D6DC;
/* Tool picker sticky header variables (dark mode) */
--tool-header-bg: #2A2F36;
--tool-header-border: #3A4047;
--tool-header-text: #D0D6DC;
--tool-header-badge-bg: #4B525A;
--tool-header-badge-text: #FFFFFF;
/* Subcategory title styling (dark mode) */
--tool-subcategory-text-color: #9CA3AF; /* lighter text in dark mode as well */
--tool-subcategory-rule-color: #3A4047; /* doubly lighter (relative) line in dark */
/* Placeholder text colors (dark mode) */
--search-text-and-icon-color: #FFFFFF !important;
/* Tool panel search bar background colors (dark mode) */
--tool-panel-search-bg: #1F2329;
--tool-panel-search-border-bottom: #4B525A;
}
/* Dropzone drop state styling */
-1
View File
@@ -32,7 +32,6 @@ export interface ButtonConfig {
id: string;
name: string;
icon: React.ReactNode;
tooltip: string;
isRound?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl';
onClick: () => void;
+1 -1
View File
@@ -34,7 +34,7 @@ export interface ToolResult {
}
export interface ToolConfiguration {
maxFiles: number;
maxFiles?: number;
supportedFormats?: string[];
}