Feature/v2/all tools sidebar (#4151)

# Description of Changes

- Added the all tools sidebar
- Added a TextFit component that shrinks text to fit containers
- Added a TopToolIcon on the nav, that animates down to give users
feedback on what tool is selected
- Added the baseToolRegistry, to replace the old pattern of listing
tools, allowing us to clean up the ToolRegistry code
- Fixed Mantine light/dark theme race condition 
- General styling tweaks

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
EthanHealy01
2025-08-19 13:31:09 +01:00
committed by GitHub
parent c1b7911518
commit 8f32082145
40 changed files with 2617 additions and 451 deletions
@@ -0,0 +1,49 @@
import React, { useMemo } from 'react';
import { Box, Stack, Text } from '@mantine/core';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next';
import { useToolSections } from '../../hooks/useToolSections';
import SubcategoryHeader from './shared/SubcategoryHeader';
import NoToolsFound from './shared/NoToolsFound';
interface SearchResultsProps {
filteredTools: [string, ToolRegistryEntry][];
onSelect: (id: string) => void;
}
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }) => {
const { t } = useTranslation();
const { searchGroups } = useToolSections(filteredTools);
if (searchGroups.length === 0) {
return <NoToolsFound />;
}
return (
<Stack p="sm" gap="xs">
{searchGroups.map(group => (
<Box key={group.subcategory} w="100%">
<SubcategoryHeader label={t(`toolPicker.subcategories.${group.subcategory}`, group.subcategory)} />
<Stack gap="xs">
{group.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
))}
{/* global spacer to allow scrolling past last row in search mode */}
<div aria-hidden style={{ height: 200 }} />
</Stack>
);
};
export default SearchResults;
+36 -16
View File
@@ -1,10 +1,11 @@
import React from 'react';
import { TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
import SearchResults from './SearchResults';
import ToolRenderer from './ToolRenderer';
import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
@@ -23,12 +24,13 @@ export default function ToolPanel() {
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
handleBackToTools
} = useToolPanelState();
} = useToolWorkflow();
const { selectedToolKey, handleToolSelect } = useToolSelection();
const { setPreviewFile } = useWorkbenchState();
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
const { setPreviewFile } = useToolWorkflow();
return (
<div
@@ -52,23 +54,39 @@ export default function ToolPanel() {
}}
>
{/* Search Bar - Always visible at the top */}
<div className="mb-4">
<TextInput
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
<div
style={{
backgroundColor: 'var(--tool-panel-search-bg)',
borderBottom: '1px solid var(--tool-panel-search-border-bottom)',
padding: '0.75rem 1rem',
}}
>
<ToolSearch
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
autoComplete="off"
size="sm"
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
{leftPanelView === 'toolPicker' ? (
{searchQuery.trim().length > 0 ? (
// Searching view (replaces both picker and content)
<div className="flex-1 flex flex-col">
<div className="flex-1 min-h-0">
<SearchResults
filteredTools={filteredTools}
onSelect={handleToolSelect}
/>
</div>
</div>
) : leftPanelView === 'toolPicker' ? (
// Tool Picker View
<div className="flex-1 flex flex-col">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={handleToolSelect}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/>
</div>
) : (
@@ -76,10 +94,12 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col">
{/* Tool content */}
<div className="flex-1 min-h-0">
<ToolRenderer
selectedToolKey={selectedToolKey || ''}
onPreviewFile={setPreviewFile}
/>
{selectedToolKey && (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
)}
</div>
</div>
)}
+214 -27
View File
@@ -1,43 +1,230 @@
import React from "react";
import { Box, Text, Stack, Button } from "@mantine/core";
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
import { Box, Text, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistry } from "../../types/tool";
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
import ToolButton from "./toolPicker/ToolButton";
import "./toolPicker/ToolPicker.css";
import { useToolSections } from "../../hooks/useToolSections";
import SubcategoryHeader from "./shared/SubcategoryHeader";
import NoToolsFound from "./shared/NoToolsFound";
interface ToolPickerProps {
selectedToolKey: string | null;
onSelect: (id: string) => void;
/** Pre-filtered tools to display */
filteredTools: [string, ToolRegistry[string]][];
filteredTools: [string, ToolRegistryEntry][];
isSearching?: boolean;
}
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProps) => {
// Helper function to render tool buttons for a subcategory
const renderToolButtons = (
subcategory: any,
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true
) => (
<Box key={subcategory.subcategory} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={subcategory.subcategory} />
)}
<Stack gap="xs">
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
);
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
const scrollableRef = useRef<HTMLDivElement>(null);
const quickHeaderRef = useRef<HTMLDivElement>(null);
const allHeaderRef = useRef<HTMLDivElement>(null);
const quickAccessRef = useRef<HTMLDivElement>(null);
const allToolsRef = useRef<HTMLDivElement>(null);
// On resize adjust headers height to offset height
useLayoutEffect(() => {
const update = () => {
if (quickHeaderRef.current) {
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight);
}
if (allHeaderRef.current) {
setAllHeaderHeight(allHeaderRef.current.offsetHeight);
}
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
const { sections: visibleSections } = useToolSections(filteredTools);
const quickSection = useMemo(
() => visibleSections.find(s => (s as any).key === 'quick'),
[visibleSections]
);
const allSection = useMemo(
() => visibleSections.find(s => (s as any).key === 'all'),
[visibleSections]
);
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
const container = scrollableRef.current;
const target = ref.current;
if (container && target) {
const stackedOffset = ref === allToolsRef
? (quickHeaderHeight + allHeaderHeight)
: quickHeaderHeight;
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
container.scrollTo({
top: Math.max(0, top),
behavior: "smooth"
});
}
};
// Build flat list by subcategory for search mode
const { searchGroups } = useToolSections(isSearching ? filteredTools : []);
return (
<Box>
<Stack align="flex-start">
{filteredTools.length === 0 ? (
<Text c="dimmed" size="sm">
{t("toolPicker.noToolsFound", "No tools found")}
</Text>
<Box
h="100vh"
style={{
display: "flex",
flexDirection: "column",
background: "var(--bg-toolbar)"
}}
>
<Box
ref={scrollableRef}
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
minHeight: 0,
height: "100%"
}}
className="tool-picker-scrollable"
>
{isSearching ? (
<Stack p="sm" gap="xs">
{searchGroups.length === 0 ? (
<NoToolsFound />
) : (
searchGroups.map(group => renderToolButtons(group, selectedToolKey, onSelect))
)}
</Stack>
) : (
filteredTools.map(([id, { icon, name }]) => (
<Button
key={id}
data-testid={`tool-${id}`}
variant={selectedToolKey === id ? "filled" : "subtle"}
onClick={() => onSelect(id)}
size="md"
radius="md"
leftSection={icon}
fullWidth
justify="flex-start"
<>
{quickSection && (
<>
<div
ref={quickHeaderRef}
style={{
position: "sticky",
top: 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
marginBottom: -1,
padding: "0.5rem 1rem",
fontWeight: 700,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
onClick={() => scrollTo(quickAccessRef)}
>
{name}
</Button>
))
<span>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
borderRadius: ".5rem",
padding: "0.125rem 0.5rem",
fontSize: ".75rem",
fontWeight: 700
}}
>
{quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
</div>
<Box ref={quickAccessRef} w="100%">
<Stack p="sm" gap="xs">
{quickSection?.subcategories.map(sc =>
renderToolButtons(sc, selectedToolKey, onSelect, false)
)}
</Stack>
</Box>
</>
)}
</Stack>
{allSection && (
<>
<div
ref={allHeaderRef}
style={{
position: "sticky",
top: quickSection ? quickHeaderHeight - 1: 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 700,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
onClick={() => scrollTo(allToolsRef)}
>
<span>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
borderRadius: ".5rem",
padding: "0.125rem 0.5rem",
fontSize: ".75rem",
fontWeight: 700
}}
>
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
</div>
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map(sc =>
renderToolButtons(sc, selectedToolKey, onSelect, true)
)}
</Stack>
</Box>
</>
)}
{!quickSection && !allSection && <NoToolsFound />}
{/* bottom spacer to allow scrolling past the last row */}
<div aria-hidden style={{ height: 200 }} />
</>
)}
</Box>
</Box>
);
};
@@ -26,7 +26,7 @@ const ToolRenderer = ({
// Wrap lazy-loaded component with Suspense
return (
<Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
<Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
<ToolComponent
onPreviewFile={onPreviewFile}
onComplete={onComplete}
@@ -4,6 +4,7 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { useTranslation } from "react-i18next";
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
import { getConversionEndpoints } from "../../../data/toolsTaxonomy";
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
import { useFileContext } from "../../../contexts/FileContext";
import { detectFileExtension } from "../../../utils/fileUtils";
@@ -43,15 +44,7 @@ const ConvertSettings = ({
const { setSelectedFiles } = useFileSelectionActions();
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
const allEndpoints = useMemo(() => {
const endpoints = new Set<string>();
Object.values(EXTENSION_TO_ENDPOINT).forEach(toEndpoints => {
Object.values(toEndpoints).forEach(endpoint => {
endpoints.add(endpoint);
});
});
return Array.from(endpoints);
}, []);
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
@@ -0,0 +1,15 @@
import React from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
const NoToolsFound: React.FC = () => {
const { t } = useTranslation();
return (
<Text c="dimmed" size="sm" p="sm">
{t("toolPicker.noToolsFound", "No tools found")}
</Text>
);
};
export default NoToolsFound;
@@ -0,0 +1,17 @@
import React from 'react';
interface SubcategoryHeaderProps {
label: string;
mt?: string | number;
mb?: string | number;
}
export const SubcategoryHeader: React.FC<SubcategoryHeaderProps> = ({ label, mt = '1rem', mb = '0.25rem' }) => (
<div className="tool-subcategory-row" style={{ marginLeft: '1rem', marginRight: '1rem', marginTop: mt, marginBottom: mb }}>
<div className="tool-subcategory-row-rule" />
<span className="tool-subcategory-row-title">{label}</span>
<div className="tool-subcategory-row-rule" />
</div>
);
export default SubcategoryHeader;
@@ -0,0 +1,50 @@
import React from "react";
import { Button } from "@mantine/core";
import { Tooltip } from "../../shared/Tooltip";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import FitText from "../../shared/FitText";
interface ToolButtonProps {
id: string;
tool: ToolRegistryEntry;
isSelected: boolean;
onSelect: (id: string) => void;
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
const handleClick = (id: string) => {
if (tool.link) {
// Open external link in new tab
window.open(tool.link, '_blank', 'noopener,noreferrer');
return;
}
// Normal tool selection
onSelect(id);
};
return (
<Tooltip content={tool.description} position="right" arrow={true} delay={500}>
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={()=> handleClick(id)}
size="md"
radius="md"
leftSection={<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%' }}
/>
</Button>
</Tooltip>
);
};
export default ToolButton;
@@ -0,0 +1,79 @@
.tool-picker-scrollable {
overflow-y: auto !important;
overflow-x: hidden !important;
scrollbar-width: thin;
scrollbar-color: var(--mantine-color-gray-4) transparent;
}
.tool-picker-scrollable::-webkit-scrollbar {
width: 0.375rem;
}
.tool-picker-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.tool-picker-scrollable::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-4);
border-radius: 0.1875rem;
}
.tool-picker-scrollable::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-5);
}
.search-input {
margin: 1rem;
}
.tool-subcategory-title {
text-transform: uppercase;
padding-bottom: 0.5rem;
font-size: 0.75rem;
color: var(--tool-subcategory-text-color);
/* Align the text with tool labels to account for icon gutter */
padding-left: 1rem;
}
/* New row-style subcategory header with rule */
.tool-subcategory-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.tool-subcategory-row-title {
text-transform: uppercase;
font-weight: 600;
font-size: 0.75rem;
color: var(--tool-subcategory-text-color);
white-space: nowrap;
overflow: visible;
}
.tool-subcategory-row-rule {
height: 1px;
background-color: var(--tool-subcategory-rule-color);
flex: 1 1 auto;
}
/* Compact tool buttons */
.tool-button {
font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
.tool-button .mantine-Button-label {
font-size: .85rem;
}
.tool-button-icon {
font-size: 1rem;
line-height: 1;
}
.search-input-container {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
@@ -0,0 +1,129 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import { Stack, Button, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { TextInput } from "../../shared/TextInput";
import './ToolPicker.css';
interface ToolSearchProps {
value: string;
onChange: (value: string) => void;
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
onToolSelect?: (toolId: string) => void;
mode: 'filter' | 'dropdown';
selectedToolKey?: string | null;
}
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
mode = 'filter',
selectedToolKey
}: ToolSearchProps) => {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const filteredTools = useMemo(() => {
if (!value.trim()) return [];
return Object.entries(toolRegistry)
.filter(([id, tool]) => {
if (mode === 'dropdown' && id === selectedToolKey) return false;
return tool.name.toLowerCase().includes(value.toLowerCase()) ||
tool.description.toLowerCase().includes(value.toLowerCase());
})
.slice(0, 6)
.map(([id, tool]) => ({ id, tool }));
}, [value, toolRegistry, mode, selectedToolKey]);
const handleSearchChange = (searchValue: string) => {
onChange(searchValue);
if (mode === 'dropdown') {
setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0);
}
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const searchInput = (
<div className="search-input-container">
<TextInput
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
icon={<span className="material-symbols-rounded">search</span>}
autoComplete="off"
/>
</div>
);
if (mode === 'filter') {
return searchInput;
}
return (
<div ref={searchRef} style={{ position: 'relative' }}>
{searchInput}
{dropdownOpen && filteredTools.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '8px',
marginTop: '4px',
maxHeight: '300px',
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
}}
>
<Stack gap="xs" style={{ padding: '8px' }}>
{filteredTools.map(({ id, tool }) => (
<Button
key={id}
variant="subtle"
onClick={() => onToolSelect && onToolSelect(id)}
leftSection={
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
{tool.icon}
</div>
}
fullWidth
justify="flex-start"
style={{
borderRadius: '6px',
color: 'var(--tools-text-and-icon-color)',
padding: '8px 12px'
}}
>
<div style={{ textAlign: 'left' }}>
<div style={{ fontWeight: 500 }}>{tool.name}</div>
<Text size="xs" c="dimmed" style={{ marginTop: '2px' }}>
{tool.description}
</Text>
</div>
</Button>
))}
</Stack>
</div>
)}
</div>
);
};
export default ToolSearch;