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
@@ -34,7 +34,7 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
const page = await pdf.getPage(i);
const annotations = await page.getAnnotations({ intent: 'display' });
annotations.forEach(annotation => {
annotations.forEach((annotation: any) => {
if (annotation.subtype === 'Widget' && annotation.fieldType === 'Sig') {
foundSignature = true;
}
+7 -1
View File
@@ -18,7 +18,13 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
if (stored && ['light', 'dark', 'rainbow'].includes(stored)) {
return stored as ThemeMode;
}
return initialTheme;
try {
// Fallback to OS preference if available
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : initialTheme;
} catch {
return initialTheme;
}
});
// Track rapid toggles for easter egg
+30 -146
View File
@@ -1,137 +1,14 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import ApiIcon from "@mui/icons-material/Api";
import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
import LockIcon from "@mui/icons-material/Lock";
import BrandingWatermarkIcon from "@mui/icons-material/BrandingWatermark";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, ToolRegistry } from "../types/tool";
// Add entry here with maxFiles, endpoints, and lazy component
const toolDefinitions: Record<string, ToolDefinition> = {
split: {
id: "split",
icon: <ContentCutIcon />,
component: React.lazy(() => import("../tools/Split")),
maxFiles: 1,
category: "manipulation",
description: "Split PDF files into smaller parts",
endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"]
},
compress: {
id: "compress",
icon: <ZoomInMapIcon />,
component: React.lazy(() => import("../tools/Compress")),
maxFiles: -1,
category: "optimization",
description: "Reduce PDF file size",
endpoints: ["compress-pdf"]
},
convert: {
id: "convert",
icon: <SwapHorizIcon />,
component: React.lazy(() => import("../tools/Convert")),
maxFiles: -1,
category: "manipulation",
description: "Change to and from PDF and other formats",
endpoints: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"],
supportedFormats: [
// Microsoft Office
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
// OpenDocument
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
// Text formats
"txt", "text", "xml", "rtf", "html", "lwp", "md",
// Images
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
// StarOffice
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
]
},
swagger: {
id: "swagger",
icon: <ApiIcon />,
component: React.lazy(() => import("../tools/SwaggerUI")),
maxFiles: 0,
category: "utility",
description: "Open API documentation",
endpoints: ["swagger-ui"]
},
ocr: {
id: "ocr",
icon: <span className="material-symbols-rounded font-size-20">
quick_reference_all
</span>,
component: React.lazy(() => import("../tools/OCR")),
maxFiles: -1,
category: "utility",
description: "Extract text from images using OCR",
endpoints: ["ocr-pdf"]
},
sanitize: {
id: "sanitize",
icon: <CleaningServicesIcon />,
component: React.lazy(() => import("../tools/Sanitize")),
maxFiles: -1,
category: "security",
description: "Remove potentially harmful elements from PDF files",
endpoints: ["sanitize-pdf"]
},
addPassword: {
id: "addPassword",
icon: <LockIcon />,
component: React.lazy(() => import("../tools/AddPassword")),
maxFiles: -1,
category: "security",
description: "Add password protection and restrictions to PDF files",
endpoints: ["add-password"]
},
changePermissions: {
id: "changePermissions",
icon: <LockIcon />,
component: React.lazy(() => import("../tools/ChangePermissions")),
maxFiles: -1,
category: "security",
description: "Change document restrictions and permissions",
endpoints: ["add-password"]
},
watermark: {
id: "watermark",
icon: <BrandingWatermarkIcon />,
component: React.lazy(() => import("../tools/AddWatermark")),
maxFiles: -1,
category: "security",
description: "Add text or image watermarks to PDF files",
endpoints: ["add-watermark"]
},
removePassword: {
id: "removePassword",
icon: <LockOpenIcon />,
component: React.lazy(() => import("../tools/RemovePassword")),
maxFiles: -1,
category: "security",
description: "Remove password protection from PDF files",
endpoints: ["remove-password"]
},
};
interface ToolManagementResult {
selectedToolKey: string | null;
selectedTool: Tool | null;
selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: string[];
toolRegistry: ToolRegistry;
toolRegistry: Record<string, ToolRegistryEntry>;
selectTool: (toolKey: string) => void;
clearToolSelection: () => void;
setToolSelectedFileIds: (fileIds: string[]) => void;
@@ -143,33 +20,41 @@ export const useToolManagement = (): ToolManagementResult => {
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
const allEndpoints = Array.from(new Set(
Object.values(toolDefinitions).flatMap(tool => tool.endpoints || [])
));
// Build endpoints list from registry entries with fallback to legacy mapping
const baseRegistry = useFlatToolRegistry();
const registryDerivedEndpoints = useMemo(() => {
const endpointsByTool: Record<string, string[]> = {};
Object.entries(baseRegistry).forEach(([key, entry]) => {
if (entry.endpoints && entry.endpoints.length > 0) {
endpointsByTool[key] = entry.endpoints;
}
});
return endpointsByTool;
}, [baseRegistry]);
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
if (endpointsLoading) return true;
const tool = toolDefinitions[toolKey];
if (!tool?.endpoints) return true;
return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus]);
const endpoints = baseRegistry[toolKey]?.endpoints || [];
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const toolRegistry: ToolRegistry = useMemo(() => {
const availableTools: ToolRegistry = {};
Object.keys(toolDefinitions).forEach(toolKey => {
const toolRegistry: Record<string, ToolRegistryEntry> = useMemo(() => {
const availableToolRegistry: Record<string, ToolRegistryEntry> = {};
Object.keys(baseRegistry).forEach(toolKey => {
if (isToolAvailable(toolKey)) {
const toolDef = toolDefinitions[toolKey];
availableTools[toolKey] = {
...toolDef,
name: t(`${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)),
title: t(`${toolKey}.title`, toolDef.description || toolKey),
description: t(`${toolKey}.desc`, toolDef.description || `${toolKey} tool`)
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
availableToolRegistry[toolKey] = {
...baseTool,
name: t(baseTool.name),
description: t(baseTool.description)
};
}
});
return availableTools;
}, [t, isToolAvailable]);
return availableToolRegistry;
}, [isToolAvailable, t, baseRegistry]);
useEffect(() => {
if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) {
@@ -197,7 +82,6 @@ export const useToolManagement = (): ToolManagementResult => {
selectedTool,
toolSelectedFileIds,
toolRegistry,
selectTool,
clearToolSelection,
setToolSelectedFileIds,
+88
View File
@@ -0,0 +1,88 @@
import { useMemo } from 'react';
import { SUBCATEGORY_ORDER, ToolCategory, ToolRegistryEntry } from '../data/toolsTaxonomy';
import { useTranslation } from 'react-i18next';
type GroupedTools = {
[category: string]: {
[subcategory: string]: Array<{ id: string; tool: ToolRegistryEntry }>;
};
};
export function useToolSections(filteredTools: [string, ToolRegistryEntry][]) {
const { t } = useTranslation();
const groupedTools = useMemo(() => {
const grouped: GroupedTools = {};
filteredTools.forEach(([id, tool]) => {
const category = tool.category;
const subcategory = tool.subcategory;
if (!grouped[category]) grouped[category] = {};
if (!grouped[category][subcategory]) grouped[category][subcategory] = [];
grouped[category][subcategory].push({ id, tool });
});
return grouped;
}, [filteredTools]);
const sections = useMemo(() => {
const getOrderIndex = (name: string) => {
const idx = SUBCATEGORY_ORDER.indexOf(name as any);
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
};
const quick: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const all: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
Object.entries(groupedTools).forEach(([origCat, subs]) => {
const upperCat = origCat.toUpperCase();
Object.entries(subs).forEach(([sub, tools]) => {
if (!all[sub]) all[sub] = [];
all[sub].push(...tools);
});
if (upperCat === ToolCategory.RECOMMENDED_TOOLS.toUpperCase()) {
Object.entries(subs).forEach(([sub, tools]) => {
if (!quick[sub]) quick[sub] = [];
quick[sub].push(...tools);
});
}
});
const sortSubs = (obj: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>>) =>
Object.entries(obj)
.sort(([a], [b]) => {
const ai = getOrderIndex(a);
const bi = getOrderIndex(b);
if (ai !== bi) return ai - bi;
return a.localeCompare(b);
})
.map(([subcategory, tools]) => ({ subcategory, tools }));
const built = [
{ key: 'quick', title: t('toolPicker.quickAccess', 'QUICK ACCESS'), subcategories: sortSubs(quick) },
{ key: 'all', title: t('toolPicker.allTools', 'ALL TOOLS'), subcategories: sortSubs(all) }
];
return built.filter(section => section.subcategories.some(sc => sc.tools.length > 0));
}, [groupedTools]);
const searchGroups = useMemo(() => {
const subMap: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const seen = new Set<string>();
filteredTools.forEach(([id, tool]) => {
if (seen.has(id)) return;
seen.add(id);
const sub = tool.subcategory;
if (!subMap[sub]) subMap[sub] = [];
subMap[sub].push({ id, tool });
});
return Object.entries(subMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([subcategory, tools]) => ({ subcategory, tools }));
}, [filteredTools]);
return { sections, searchGroups };
}