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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2617 additions and 451 deletions

View File

@ -1,4 +1,4 @@
<svg width="146" height="157" viewBox="0 0 146 157" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#E6E6E6" fill-opacity="0.9"/>
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#E6E6E6" fill-opacity="0.8"/>
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#E6E6E6" fill-opacity="0.4"/>
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#E6E6E6" fill-opacity="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 366 B

After

Width:  |  Height:  |  Size: 366 B

View File

@ -382,6 +382,10 @@
"title": "Add image",
"desc": "Adds a image onto a set location on the PDF"
},
"attachments": {
"title": "Add Attachments",
"desc": "Add or remove embedded files (attachments) to/from a PDF"
},
"watermark": {
"title": "Add Watermark",
"desc": "Add a custom watermark to your PDF document."
@ -597,6 +601,30 @@
"replace-color": {
"title": "Advanced Colour options",
"desc": "Replace colour for text and background in PDF and invert full colour of pdf to reduce file size"
},
"fakeScan": {
"title": "Fake Scan",
"desc": "Create a PDF that looks like it was scanned"
},
"editTableOfContents": {
"title": "Edit Table of Contents",
"desc": "Add or edit bookmarks and table of contents in PDF documents"
},
"automate": {
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
},
"manageCertificates": {
"title": "Manage Certificates",
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
},
"read": {
"title": "Read",
"desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
},
"reorganizePages": {
"title": "Reorganize Pages",
"desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
}
},
"viewPdf": {
@ -777,6 +805,15 @@
"upload": "Add image",
"submit": "Add image"
},
"attachments": {
"tags": "attachments,add,remove,embed,file",
"title": "Add Attachments",
"header": "Add Attachments",
"add": "Add Attachment",
"remove": "Remove Attachment",
"embed": "Embed Attachment",
"submit": "Add Attachments"
},
"watermark": {
"title": "Add Watermark",
"desc": "Add text or image watermarks to PDF files",
@ -1740,7 +1777,7 @@
"title": "How we use Cookies",
"description": {
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
"2": "If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
},
"acceptAllBtn": "Okay",
"acceptNecessaryBtn": "No Thanks",
@ -1764,7 +1801,7 @@
"1": "Strictly Necessary Cookies",
"2": "Always Enabled"
},
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off."
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
},
"analytics": {
"title": "Analytics",
@ -1823,7 +1860,31 @@
},
"toolPicker": {
"searchPlaceholder": "Search tools...",
"noToolsFound": "No tools found"
"noToolsFound": "No tools found",
"allTools": "ALL TOOLS",
"quickAccess": "QUICK ACCESS",
"subcategories": {
"Signing": "Signing",
"Document Security": "Document Security",
"Verification": "Verification",
"Document Review": "Document Review",
"Page Formatting": "Page Formatting",
"Extraction": "Extraction",
"Removal": "Removal",
"Automation": "Automation",
"General": "General",
"Advanced Formatting": "Advanced Formatting",
"Developer Tools": "Developer Tools"
}
},
"quickAccess": {
"read": "Read",
"sign": "Sign",
"automate": "Automate",
"files": "Files",
"activity": "Activity",
"config": "Config",
"allTools": "All Tools"
},
"fileUpload": {
"selectFile": "Select a file",

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 = () => {

View File

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

View File

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

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>

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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>
)}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

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

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,

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

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

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

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 */

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;

View File

@ -34,7 +34,7 @@ export interface ToolResult {
}
export interface ToolConfiguration {
maxFiles: number;
maxFiles?: number;
supportedFormats?: string[];
}