mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-22 04:09:22 +00:00
Feature/v2/all tools sidebar (#4151)
# Description of Changes - Added the all tools sidebar - Added a TextFit component that shrinks text to fit containers - Added a TopToolIcon on the nav, that animates down to give users feedback on what tool is selected - Added the baseToolRegistry, to replace the old pattern of listing tools, allowing us to clean up the ToolRegistry code - Fixed Mantine light/dark theme race condition - General styling tweaks --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
parent
c1b7911518
commit
8f32082145
@ -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 |
@ -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 you’d 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 can’t 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",
|
||||
|
@ -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 = () => {
|
||||
|
61
frontend/src/components/shared/AllToolsNavButton.tsx
Normal file
61
frontend/src/components/shared/AllToolsNavButton.tsx
Normal 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;
|
||||
|
||||
|
79
frontend/src/components/shared/FitText.tsx
Normal file
79
frontend/src/components/shared/FitText.tsx
Normal 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;
|
||||
|
||||
|
@ -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',
|
||||
name: t("quickAccess.sign", "Sign"),
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
signature
|
||||
</span>,
|
||||
tooltip: 'Sign your document',
|
||||
<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',
|
||||
name: t("quickAccess.activity", "Activity"),
|
||||
icon:
|
||||
<span className="material-symbols-rounded font-size-20">
|
||||
vital_signs
|
||||
</span>,
|
||||
tooltip: 'View activity and analytics',
|
||||
<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,54 +117,22 @@ 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 */}
|
||||
@ -241,32 +157,34 @@ 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>
|
||||
))}
|
||||
@ -279,25 +197,23 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
{buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<Tooltip key={config.id} label={config.tooltip} position="right">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size={config.size || 'lg'}
|
||||
variant="subtle"
|
||||
onClick={config.onClick}
|
||||
style={getButtonStyle(config)}
|
||||
className={isButtonActive(config) ? 'activeIconScale' : ''}
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
|
||||
data-testid={`${config.id}-button`}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{config.icon}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
<span className={`button-text ${isButtonActive(config) ? 'active' : 'inactive'}`}>
|
||||
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
@ -34,22 +34,19 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColorSchemeScript defaultColorScheme={mantineColorScheme} />
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
theme={mantineTheme}
|
||||
defaultColorScheme={mantineColorScheme}
|
||||
forceColorScheme={mantineColorScheme}
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
theme={mantineTheme}
|
||||
defaultColorScheme={mantineColorScheme}
|
||||
forceColorScheme={mantineColorScheme}
|
||||
>
|
||||
<div
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
<div
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
</>
|
||||
{children}
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
104
frontend/src/components/shared/TextInput.tsx
Normal file
104
frontend/src/components/shared/TextInput.tsx
Normal 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>
|
||||
);
|
||||
});
|
@ -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);
|
||||
}
|
||||
|
112
frontend/src/components/shared/fitText/FitText.README.md
Normal file
112
frontend/src/components/shared/fitText/FitText.README.md
Normal 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.
|
||||
|
||||
|
102
frontend/src/components/shared/fitText/textFit.ts
Normal file
102
frontend/src/components/shared/fitText/textFit.ts
Normal 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]);
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
||||
*
|
||||
* When a user selects a tool from the All Tools list, this component displays the tool's
|
||||
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
||||
* tool is currently active and offers a back button to return to the All Tools list.
|
||||
*
|
||||
* Features:
|
||||
* - Shows tool icon and name when a tool is selected
|
||||
* - Hover to reveal back arrow for returning to All Tools
|
||||
* - Smooth slide-down/slide-up animations
|
||||
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
|
||||
import FitText from '../FitText';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
interface ActiveToolButtonProps {
|
||||
activeButton: string;
|
||||
setActiveButton: (id: string) => void;
|
||||
}
|
||||
|
||||
const NAV_IDS = ['read', 'sign', 'automate'];
|
||||
|
||||
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
|
||||
|
||||
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
|
||||
const indicatorShouldShow = Boolean(
|
||||
selectedToolKey && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)
|
||||
);
|
||||
|
||||
// Local animation and hover state
|
||||
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
|
||||
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
|
||||
const [replayAnim, setReplayAnim] = useState<boolean>(false);
|
||||
const [isAnimating, setIsAnimating] = useState<boolean>(false);
|
||||
const [isBackHover, setIsBackHover] = useState<boolean>(false);
|
||||
const prevKeyRef = useRef<string | null>(null);
|
||||
const collapseTimeoutRef = useRef<number | null>(null);
|
||||
const animTimeoutRef = useRef<number | null>(null);
|
||||
const replayRafRef = useRef<number | null>(null);
|
||||
|
||||
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey };
|
||||
|
||||
const clearTimers = () => {
|
||||
if (collapseTimeoutRef.current) {
|
||||
window.clearTimeout(collapseTimeoutRef.current);
|
||||
collapseTimeoutRef.current = null;
|
||||
}
|
||||
if (animTimeoutRef.current) {
|
||||
window.clearTimeout(animTimeoutRef.current);
|
||||
animTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const playGrowDown = () => {
|
||||
clearTimers();
|
||||
setIndicatorTool(selectedTool);
|
||||
setIndicatorVisible(true);
|
||||
// Force a replay even if the class is already applied
|
||||
setReplayAnim(false);
|
||||
if (replayRafRef.current) {
|
||||
cancelAnimationFrame(replayRafRef.current);
|
||||
replayRafRef.current = null;
|
||||
}
|
||||
replayRafRef.current = requestAnimationFrame(() => {
|
||||
setReplayAnim(true);
|
||||
});
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setReplayAnim(false);
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const firstShow = () => {
|
||||
clearTimers();
|
||||
setIndicatorTool(selectedTool);
|
||||
setIndicatorVisible(true);
|
||||
setIsAnimating(true);
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const triggerCollapse = () => {
|
||||
clearTimers();
|
||||
setIndicatorVisible(false);
|
||||
setIsAnimating(true);
|
||||
collapseTimeoutRef.current = window.setTimeout(() => {
|
||||
setIndicatorTool(null);
|
||||
prevKeyRef.current = null;
|
||||
setIsAnimating(false);
|
||||
collapseTimeoutRef.current = null;
|
||||
}, 500); // match CSS transition duration
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (indicatorShouldShow) {
|
||||
clearTimers();
|
||||
if (!indicatorVisible) {
|
||||
firstShow();
|
||||
return;
|
||||
}
|
||||
if (!indicatorTool) {
|
||||
firstShow();
|
||||
} else if (isSwitchingToNewTool()) {
|
||||
playGrowDown();
|
||||
} else {
|
||||
// keep reference in sync
|
||||
prevKeyRef.current = (selectedToolKey as string) || null;
|
||||
}
|
||||
} else if (indicatorTool || indicatorVisible) {
|
||||
triggerCollapse();
|
||||
}
|
||||
}, [indicatorShouldShow, selectedTool, selectedToolKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
if (replayRafRef.current) {
|
||||
cancelAnimationFrame(replayRafRef.current);
|
||||
replayRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ overflow: 'visible' }} className={`current-tool-slot ${indicatorVisible ? 'visible' : ''} ${replayAnim ? 'replay' : ''}`}>
|
||||
{indicatorTool && (
|
||||
<div className="current-tool-content">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
|
||||
<ActionIcon
|
||||
size={'xl'}
|
||||
variant="subtle"
|
||||
onMouseEnter={() => setIsBackHover(true)}
|
||||
onMouseLeave={() => setIsBackHover(false)}
|
||||
onClick={() => {
|
||||
setActiveButton('tools');
|
||||
handleBackToTools();
|
||||
}}
|
||||
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
|
||||
style={{
|
||||
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
|
||||
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{isBackHover ? (
|
||||
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
|
||||
) : (
|
||||
indicatorTool.icon
|
||||
)}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<FitText
|
||||
as="span"
|
||||
text={indicatorTool.name}
|
||||
lines={3}
|
||||
minimumFontScale={0.4}
|
||||
className="button-text active current-tool-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveToolButton;
|
||||
|
||||
|
@ -69,6 +69,8 @@
|
||||
font-size: 0.75rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.all-tools-text.active {
|
||||
@ -111,6 +113,22 @@
|
||||
font-size: 0.75rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Allow wrapping under the active top indicator; constrain to two lines */
|
||||
.current-tool-label {
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* show up to two lines */
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: normal;
|
||||
hyphens: manual;
|
||||
}
|
||||
|
||||
.button-text.active {
|
||||
@ -177,3 +195,70 @@
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
}
|
||||
|
||||
/* Animated current tool indicator that slides in from the top and pushes content down */
|
||||
/* Container grows down so it pushes items below during animation */
|
||||
.current-tool-slot {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 450ms ease-out, opacity 300ms ease-out;
|
||||
}
|
||||
|
||||
.current-tool-enter {
|
||||
animation: currentToolGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
.current-tool-slot.visible {
|
||||
max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */
|
||||
opacity: 1;
|
||||
border-bottom: 1px solid var(--color-gray-300);
|
||||
padding-bottom: 0.75rem; /* push border down for spacing */
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Replay the grow-down animation when switching tools while visible */
|
||||
.current-tool-slot.replay .current-tool-content {
|
||||
animation: currentToolGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
/* Also animate the container itself when replaying so it "pushes down" again */
|
||||
.current-tool-slot.replay {
|
||||
animation: currentToolGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes currentToolGrowDown {
|
||||
0% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 7.875rem; /* enough space for icon + up to 3-line label (126px) */
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Divider that animates growing from top */
|
||||
.current-tool-divider {
|
||||
width: 3.75rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 0.5rem auto 0.5rem auto;
|
||||
transform-origin: top;
|
||||
animation: dividerGrowDown 350ms ease-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes dividerGrowDown {
|
||||
0% {
|
||||
transform: scaleY(0);
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
import { ButtonConfig } from '../../../types/sidebar';
|
||||
|
||||
// Border radius constants
|
||||
export const ROUND_BORDER_RADIUS = '0.5rem';
|
||||
|
||||
/**
|
||||
* Check if a navigation button is currently active
|
||||
*/
|
||||
export const isNavButtonActive = (
|
||||
config: ButtonConfig,
|
||||
activeButton: string,
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
): boolean => {
|
||||
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
|
||||
const isActiveByContext =
|
||||
config.type === 'navigation' &&
|
||||
leftPanelView === 'toolContent' &&
|
||||
selectedToolKey === config.id;
|
||||
const isActiveByModal =
|
||||
(config.type === 'modal' && config.id === 'files' && isFilesModalOpen) ||
|
||||
(config.type === 'modal' && config.id === 'config' && configModalOpen);
|
||||
|
||||
return isActiveByLocalState || isActiveByContext || isActiveByModal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get button styles based on active state
|
||||
*/
|
||||
export const getNavButtonStyle = (
|
||||
config: ButtonConfig,
|
||||
activeButton: string,
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
) => {
|
||||
const isActive = isNavButtonActive(
|
||||
config,
|
||||
activeButton,
|
||||
isFilesModalOpen,
|
||||
configModalOpen,
|
||||
selectedToolKey,
|
||||
leftPanelView
|
||||
);
|
||||
|
||||
if (isActive) {
|
||||
return {
|
||||
backgroundColor: `var(--icon-${config.id}-bg)`,
|
||||
color: `var(--icon-${config.id}-color)`,
|
||||
border: 'none',
|
||||
borderRadius: ROUND_BORDER_RADIUS,
|
||||
};
|
||||
}
|
||||
|
||||
// Inactive state for all buttons
|
||||
return {
|
||||
backgroundColor: 'var(--icon-inactive-bg)',
|
||||
color: 'var(--icon-inactive-color)',
|
||||
border: 'none',
|
||||
borderRadius: ROUND_BORDER_RADIUS,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Determine the active nav button based on current tool state and registry
|
||||
*/
|
||||
export const getActiveNavButton = (
|
||||
selectedToolKey: string | null,
|
||||
readerMode: boolean
|
||||
): string => {
|
||||
// Reader mode takes precedence and should highlight the Read nav item
|
||||
if (readerMode) {
|
||||
return 'read';
|
||||
}
|
||||
// If a tool is selected, highlight it immediately even if the panel view
|
||||
// transition to 'toolContent' has not completed yet. This prevents a brief
|
||||
// period of no-highlight during rapid navigation.
|
||||
return selectedToolKey ? selectedToolKey : 'tools';
|
||||
};
|
@ -0,0 +1,73 @@
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
font-size: 16px;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--search-text-and-icon-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input:read-only {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .clearButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
@ -14,6 +14,7 @@ A flexible, accessible tooltip component that supports both regular positioning
|
||||
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
|
||||
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
|
||||
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
|
||||
- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay`
|
||||
|
||||
## Behavior
|
||||
|
||||
@ -61,6 +62,7 @@ function MyComponent() {
|
||||
| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element |
|
||||
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into |
|
||||
| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo |
|
||||
| `delay` | `number` | `0` | Optional hover-open delay (ms). If omitted or 0, opens immediately |
|
||||
|
||||
### TooltipTip Interface
|
||||
|
||||
@ -106,6 +108,17 @@ interface TooltipTip {
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### Optional Hover Delay
|
||||
|
||||
```tsx
|
||||
// Show after a 1s hover
|
||||
<Tooltip content="Appears after a long hover" delay={1000} />
|
||||
|
||||
// Custom long-hover duration (2 seconds)
|
||||
<Tooltip content="Appears after 2s" delay={2000} />
|
||||
```
|
||||
|
||||
|
||||
### Custom JSX Content
|
||||
|
||||
```tsx
|
||||
@ -214,6 +227,12 @@ Links automatically get proper styling with hover states and open in new tabs wh
|
||||
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
|
||||
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
|
||||
|
||||
## Timing Details
|
||||
|
||||
- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned).
|
||||
- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps.
|
||||
- Only one tooltip can be open at a time; hovering a new trigger closes others immediately.
|
||||
|
||||
### Sidebar Tooltips
|
||||
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
|
||||
- Vertical positioning follows the trigger but clamps to viewport
|
||||
|
@ -10,7 +10,6 @@
|
||||
pointer-events: auto;
|
||||
z-index: 9999;
|
||||
transition: opacity 100ms ease-out, transform 100ms ease-out;
|
||||
min-width: 25rem;
|
||||
max-width: 50vh;
|
||||
max-height: 80vh;
|
||||
color: var(--text-primary);
|
||||
|
49
frontend/src/components/tools/SearchResults.tsx
Normal file
49
frontend/src/components/tools/SearchResults.tsx
Normal 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;
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import ToolPicker from './ToolPicker';
|
||||
import SearchResults from './SearchResults';
|
||||
import ToolRenderer from './ToolRenderer';
|
||||
import ToolSearch from './toolPicker/ToolSearch';
|
||||
import { useSidebarContext } from "../../contexts/SidebarContext";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
|
||||
@ -23,12 +24,13 @@ export default function ToolPanel() {
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
handleBackToTools
|
||||
} = useToolPanelState();
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { selectedToolKey, handleToolSelect } = useToolSelection();
|
||||
const { setPreviewFile } = useWorkbenchState();
|
||||
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
|
||||
const { setPreviewFile } = useToolWorkflow();
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -52,23 +54,39 @@ export default function ToolPanel() {
|
||||
}}
|
||||
>
|
||||
{/* Search Bar - Always visible at the top */}
|
||||
<div className="mb-4">
|
||||
<TextInput
|
||||
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--tool-panel-search-bg)',
|
||||
borderBottom: '1px solid var(--tool-panel-search-border-bottom)',
|
||||
padding: '0.75rem 1rem',
|
||||
}}
|
||||
>
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
autoComplete="off"
|
||||
size="sm"
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{leftPanelView === 'toolPicker' ? (
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
// Searching view (replaces both picker and content)
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 min-h-0">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={handleToolSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : leftPanelView === 'toolPicker' ? (
|
||||
// Tool Picker View
|
||||
<div className="flex-1 flex flex-col">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={handleToolSelect}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -76,10 +94,12 @@ export default function ToolPanel() {
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Tool content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey || ''}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
{selectedToolKey && (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,43 +1,230 @@
|
||||
import React from "react";
|
||||
import { Box, Text, Stack, Button } from "@mantine/core";
|
||||
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
|
||||
import { Box, Text, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistry } from "../../types/tool";
|
||||
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
|
||||
import ToolButton from "./toolPicker/ToolButton";
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
import { useToolSections } from "../../hooks/useToolSections";
|
||||
import SubcategoryHeader from "./shared/SubcategoryHeader";
|
||||
import NoToolsFound from "./shared/NoToolsFound";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
/** Pre-filtered tools to display */
|
||||
filteredTools: [string, ToolRegistry[string]][];
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProps) => {
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
const renderToolButtons = (
|
||||
subcategory: any,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true
|
||||
) => (
|
||||
<Box key={subcategory.subcategory} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={subcategory.subcategory} />
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
|
||||
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
|
||||
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const quickHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const allHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const quickAccessRef = useRef<HTMLDivElement>(null);
|
||||
const allToolsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// On resize adjust headers height to offset height
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
if (quickHeaderRef.current) {
|
||||
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight);
|
||||
}
|
||||
if (allHeaderRef.current) {
|
||||
setAllHeaderHeight(allHeaderRef.current.offsetHeight);
|
||||
}
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||
|
||||
const quickSection = useMemo(
|
||||
() => visibleSections.find(s => (s as any).key === 'quick'),
|
||||
[visibleSections]
|
||||
);
|
||||
const allSection = useMemo(
|
||||
() => visibleSections.find(s => (s as any).key === 'all'),
|
||||
[visibleSections]
|
||||
);
|
||||
|
||||
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
const container = scrollableRef.current;
|
||||
const target = ref.current;
|
||||
if (container && target) {
|
||||
const stackedOffset = ref === allToolsRef
|
||||
? (quickHeaderHeight + allHeaderHeight)
|
||||
: quickHeaderHeight;
|
||||
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
|
||||
container.scrollTo({
|
||||
top: Math.max(0, top),
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build flat list by subcategory for search mode
|
||||
const { searchGroups } = useToolSections(isSearching ? filteredTools : []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack align="flex-start">
|
||||
{filteredTools.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("toolPicker.noToolsFound", "No tools found")}
|
||||
</Text>
|
||||
<Box
|
||||
h="100vh"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--bg-toolbar)"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={scrollableRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
minHeight: 0,
|
||||
height: "100%"
|
||||
}}
|
||||
className="tool-picker-scrollable"
|
||||
>
|
||||
{isSearching ? (
|
||||
<Stack p="sm" gap="xs">
|
||||
{searchGroups.length === 0 ? (
|
||||
<NoToolsFound />
|
||||
) : (
|
||||
searchGroups.map(group => renderToolButtons(group, selectedToolKey, onSelect))
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
filteredTools.map(([id, { icon, name }]) => (
|
||||
<Button
|
||||
key={id}
|
||||
data-testid={`tool-${id}`}
|
||||
variant={selectedToolKey === id ? "filled" : "subtle"}
|
||||
onClick={() => onSelect(id)}
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={icon}
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
<>
|
||||
{quickSection && (
|
||||
<>
|
||||
<div
|
||||
ref={quickHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
marginBottom: -1,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 700,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
onClick={() => scrollTo(quickAccessRef)}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))
|
||||
<span>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
|
||||
<span
|
||||
style={{
|
||||
background: "var(--tool-header-badge-bg)",
|
||||
color: "var(--tool-header-badge-text)",
|
||||
borderRadius: ".5rem",
|
||||
padding: "0.125rem 0.5rem",
|
||||
fontSize: ".75rem",
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Box ref={quickAccessRef} w="100%">
|
||||
<Stack p="sm" gap="xs">
|
||||
{quickSection?.subcategories.map(sc =>
|
||||
renderToolButtons(sc, selectedToolKey, onSelect, false)
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{allSection && (
|
||||
<>
|
||||
<div
|
||||
ref={allHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: quickSection ? quickHeaderHeight - 1: 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 700,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
onClick={() => scrollTo(allToolsRef)}
|
||||
>
|
||||
<span>{t("toolPicker.allTools", "ALL TOOLS")}</span>
|
||||
<span
|
||||
style={{
|
||||
background: "var(--tool-header-badge-bg)",
|
||||
color: "var(--tool-header-badge-text)",
|
||||
borderRadius: ".5rem",
|
||||
padding: "0.125rem 0.5rem",
|
||||
fontSize: ".75rem",
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Box ref={allToolsRef} w="100%">
|
||||
<Stack p="sm" gap="xs">
|
||||
{allSection?.subcategories.map(sc =>
|
||||
renderToolButtons(sc, selectedToolKey, onSelect, true)
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!quickSection && !allSection && <NoToolsFound />}
|
||||
|
||||
{/* bottom spacer to allow scrolling past the last row */}
|
||||
<div aria-hidden style={{ height: 200 }} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
|
||||
|
15
frontend/src/components/tools/shared/NoToolsFound.tsx
Normal file
15
frontend/src/components/tools/shared/NoToolsFound.tsx
Normal 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;
|
17
frontend/src/components/tools/shared/SubcategoryHeader.tsx
Normal file
17
frontend/src/components/tools/shared/SubcategoryHeader.tsx
Normal 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;
|
50
frontend/src/components/tools/toolPicker/ToolButton.tsx
Normal file
50
frontend/src/components/tools/toolPicker/ToolButton.tsx
Normal 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;
|
79
frontend/src/components/tools/toolPicker/ToolPicker.css
Normal file
79
frontend/src/components/tools/toolPicker/ToolPicker.css
Normal 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;
|
||||
}
|
129
frontend/src/components/tools/toolPicker/ToolSearch.tsx
Normal file
129
frontend/src/components/tools/toolPicker/ToolSearch.tsx
Normal 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;
|
@ -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,7 +69,7 @@ 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
|
||||
@ -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 [];
|
||||
@ -229,8 +225,3 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly)
|
||||
export const useToolSelection = useToolWorkflow;
|
||||
export const useToolPanelState = useToolWorkflow;
|
||||
export const useWorkbenchState = useToolWorkflow;
|
||||
|
102
frontend/src/data/toolsTaxonomy.ts
Normal file
102
frontend/src/data/toolsTaxonomy.ts
Normal 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]));
|
||||
};
|
606
frontend/src/data/useTranslatedToolRegistry.tsx
Normal file
606
frontend/src/data/useTranslatedToolRegistry.tsx
Normal 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
|
||||
},
|
||||
};
|
||||
}
|
1
frontend/src/global.d.ts
vendored
1
frontend/src/global.d.ts
vendored
@ -5,3 +5,4 @@ declare module "../components/PageEditor";
|
||||
declare module "../components/Viewer";
|
||||
declare module "*.js";
|
||||
declare module '*.module.css';
|
||||
declare module 'pdfjs-dist';
|
@ -34,7 +34,7 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
|
||||
const page = await pdf.getPage(i);
|
||||
const annotations = await page.getAnnotations({ intent: 'display' });
|
||||
|
||||
annotations.forEach(annotation => {
|
||||
annotations.forEach((annotation: any) => {
|
||||
if (annotation.subtype === 'Widget' && annotation.fieldType === 'Sig') {
|
||||
foundSignature = true;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -1,137 +1,14 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||
import ApiIcon from "@mui/icons-material/Api";
|
||||
import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import BrandingWatermarkIcon from "@mui/icons-material/BrandingWatermark";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
|
||||
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
import { Tool, ToolDefinition, ToolRegistry } from "../types/tool";
|
||||
|
||||
|
||||
// Add entry here with maxFiles, endpoints, and lazy component
|
||||
const toolDefinitions: Record<string, ToolDefinition> = {
|
||||
split: {
|
||||
id: "split",
|
||||
icon: <ContentCutIcon />,
|
||||
component: React.lazy(() => import("../tools/Split")),
|
||||
maxFiles: 1,
|
||||
category: "manipulation",
|
||||
description: "Split PDF files into smaller parts",
|
||||
endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"]
|
||||
},
|
||||
compress: {
|
||||
id: "compress",
|
||||
icon: <ZoomInMapIcon />,
|
||||
component: React.lazy(() => import("../tools/Compress")),
|
||||
maxFiles: -1,
|
||||
category: "optimization",
|
||||
description: "Reduce PDF file size",
|
||||
endpoints: ["compress-pdf"]
|
||||
},
|
||||
convert: {
|
||||
id: "convert",
|
||||
icon: <SwapHorizIcon />,
|
||||
component: React.lazy(() => import("../tools/Convert")),
|
||||
maxFiles: -1,
|
||||
category: "manipulation",
|
||||
description: "Change to and from PDF and other formats",
|
||||
endpoints: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"],
|
||||
supportedFormats: [
|
||||
// Microsoft Office
|
||||
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
|
||||
// OpenDocument
|
||||
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
|
||||
// Text formats
|
||||
"txt", "text", "xml", "rtf", "html", "lwp", "md",
|
||||
// Images
|
||||
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
|
||||
// StarOffice
|
||||
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
|
||||
// Email formats
|
||||
"eml",
|
||||
// Archive formats
|
||||
"zip",
|
||||
// Other
|
||||
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
|
||||
]
|
||||
},
|
||||
swagger: {
|
||||
id: "swagger",
|
||||
icon: <ApiIcon />,
|
||||
component: React.lazy(() => import("../tools/SwaggerUI")),
|
||||
maxFiles: 0,
|
||||
category: "utility",
|
||||
description: "Open API documentation",
|
||||
endpoints: ["swagger-ui"]
|
||||
},
|
||||
ocr: {
|
||||
id: "ocr",
|
||||
icon: <span className="material-symbols-rounded font-size-20">
|
||||
quick_reference_all
|
||||
</span>,
|
||||
component: React.lazy(() => import("../tools/OCR")),
|
||||
maxFiles: -1,
|
||||
category: "utility",
|
||||
description: "Extract text from images using OCR",
|
||||
endpoints: ["ocr-pdf"]
|
||||
},
|
||||
sanitize: {
|
||||
id: "sanitize",
|
||||
icon: <CleaningServicesIcon />,
|
||||
component: React.lazy(() => import("../tools/Sanitize")),
|
||||
maxFiles: -1,
|
||||
category: "security",
|
||||
description: "Remove potentially harmful elements from PDF files",
|
||||
endpoints: ["sanitize-pdf"]
|
||||
},
|
||||
addPassword: {
|
||||
id: "addPassword",
|
||||
icon: <LockIcon />,
|
||||
component: React.lazy(() => import("../tools/AddPassword")),
|
||||
maxFiles: -1,
|
||||
category: "security",
|
||||
description: "Add password protection and restrictions to PDF files",
|
||||
endpoints: ["add-password"]
|
||||
},
|
||||
changePermissions: {
|
||||
id: "changePermissions",
|
||||
icon: <LockIcon />,
|
||||
component: React.lazy(() => import("../tools/ChangePermissions")),
|
||||
maxFiles: -1,
|
||||
category: "security",
|
||||
description: "Change document restrictions and permissions",
|
||||
endpoints: ["add-password"]
|
||||
},
|
||||
watermark: {
|
||||
id: "watermark",
|
||||
icon: <BrandingWatermarkIcon />,
|
||||
component: React.lazy(() => import("../tools/AddWatermark")),
|
||||
maxFiles: -1,
|
||||
category: "security",
|
||||
description: "Add text or image watermarks to PDF files",
|
||||
endpoints: ["add-watermark"]
|
||||
},
|
||||
removePassword: {
|
||||
id: "removePassword",
|
||||
icon: <LockOpenIcon />,
|
||||
component: React.lazy(() => import("../tools/RemovePassword")),
|
||||
maxFiles: -1,
|
||||
category: "security",
|
||||
description: "Remove password protection from PDF files",
|
||||
endpoints: ["remove-password"]
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedToolKey: string | null;
|
||||
selectedTool: Tool | null;
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolSelectedFileIds: string[];
|
||||
toolRegistry: ToolRegistry;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
selectTool: (toolKey: string) => void;
|
||||
clearToolSelection: () => void;
|
||||
setToolSelectedFileIds: (fileIds: string[]) => void;
|
||||
@ -143,33 +20,41 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
const allEndpoints = Array.from(new Set(
|
||||
Object.values(toolDefinitions).flatMap(tool => tool.endpoints || [])
|
||||
));
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const baseRegistry = useFlatToolRegistry();
|
||||
const registryDerivedEndpoints = useMemo(() => {
|
||||
const endpointsByTool: Record<string, string[]> = {};
|
||||
Object.entries(baseRegistry).forEach(([key, entry]) => {
|
||||
if (entry.endpoints && entry.endpoints.length > 0) {
|
||||
endpointsByTool[key] = entry.endpoints;
|
||||
}
|
||||
});
|
||||
return endpointsByTool;
|
||||
}, [baseRegistry]);
|
||||
|
||||
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
if (endpointsLoading) return true;
|
||||
const tool = toolDefinitions[toolKey];
|
||||
if (!tool?.endpoints) return true;
|
||||
return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true);
|
||||
}, [endpointsLoading, endpointStatus]);
|
||||
const endpoints = baseRegistry[toolKey]?.endpoints || [];
|
||||
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||
|
||||
const toolRegistry: ToolRegistry = useMemo(() => {
|
||||
const availableTools: ToolRegistry = {};
|
||||
Object.keys(toolDefinitions).forEach(toolKey => {
|
||||
const toolRegistry: Record<string, ToolRegistryEntry> = useMemo(() => {
|
||||
const availableToolRegistry: Record<string, ToolRegistryEntry> = {};
|
||||
Object.keys(baseRegistry).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
const toolDef = toolDefinitions[toolKey];
|
||||
availableTools[toolKey] = {
|
||||
...toolDef,
|
||||
name: t(`${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)),
|
||||
title: t(`${toolKey}.title`, toolDef.description || toolKey),
|
||||
description: t(`${toolKey}.desc`, toolDef.description || `${toolKey} tool`)
|
||||
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: t(baseTool.name),
|
||||
description: t(baseTool.description)
|
||||
};
|
||||
}
|
||||
});
|
||||
return availableTools;
|
||||
}, [t, isToolAvailable]);
|
||||
return availableToolRegistry;
|
||||
}, [isToolAvailable, t, baseRegistry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) {
|
||||
@ -197,7 +82,6 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
selectedTool,
|
||||
toolSelectedFileIds,
|
||||
toolRegistry,
|
||||
|
||||
selectTool,
|
||||
clearToolSelection,
|
||||
setToolSelectedFileIds,
|
||||
|
88
frontend/src/hooks/useToolSections.ts
Normal file
88
frontend/src/hooks/useToolSections.ts
Normal 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 };
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 */
|
||||
|
@ -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;
|
||||
|
@ -34,7 +34,7 @@ export interface ToolResult {
|
||||
}
|
||||
|
||||
export interface ToolConfiguration {
|
||||
maxFiles: number;
|
||||
maxFiles?: number;
|
||||
supportedFormats?: string[];
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user