mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02: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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user