change requests

This commit is contained in:
EthanHealy01 2025-10-09 02:51:47 +01:00
parent acbecc0d32
commit a085646333
54 changed files with 669 additions and 487 deletions

View File

@ -9,6 +9,7 @@ import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex';
interface FileManagerProps {
selectedTool?: Tool | null;
@ -100,7 +101,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
radius="md"
className="overflow-hidden p-0"
withCloseButton={false}
zIndex={1300}
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
styles={{
content: {
position: 'relative',

View File

@ -1,4 +1,4 @@
import React, { useRef, useState, useCallback } from 'react';
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core';
import { ColorSwatchButton } from './ColorPicker';
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
@ -252,7 +252,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
}, [selectedColor, penSize]);
// Initialize canvas settings whenever color or pen size changes
React.useEffect(() => {
useEffect(() => {
const updateCanvas = (canvas: HTMLCanvasElement | null) => {
if (!canvas) return;
const ctx = canvas.getContext('2d');

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack, Group
} from '@mantine/core';
@ -62,7 +62,7 @@ const FileEditor = ({
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
React.useEffect(() => {
useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
@ -50,7 +50,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
};
// Reset index when selection changes
React.useEffect(() => {
useEffect(() => {
if (currentFileIndex >= selectedFiles.length) {
setCurrentFileIndex(0);
}

View File

@ -44,7 +44,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
</Group>
<Box ml="md">
{sortedHistory.map((historyFile, _index) => (
{sortedHistory.map((historyFile) => (
<FileListItem
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
file={historyFile}
@ -56,7 +56,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
onDoubleClick={() => onFileDoubleClick(historyFile)}
isHistoryFile={true} // This enables "Add to Recents" in menu
isLatestVersion={false} // History files are never latest
// onAddToRecents is accessed from context by FileListItem
/>
))}
</Box>

View File

@ -40,7 +40,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
@ -188,7 +188,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onAddToRecents(file);
}}
>
{t('fileManager.restore', 'Restore')}

View File

@ -6,6 +6,7 @@ import Overview from './config/configSections/Overview';
import { createConfigNavSections } from './config/configNavSections';
import { NavKey } from './config/types';
import './AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
interface AppConfigModalProps {
opened: boolean;
@ -77,7 +78,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
centered
radius="lg"
withCloseButton={false}
zIndex={200000}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
// Types
interface LanguageSelectorProps {
@ -209,7 +210,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
width={600}
position={position}
offset={offset}
zIndex={1400}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
transitionProps={{
transition: 'scale-y',
duration: 200,
@ -265,7 +266,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
zIndex: 1400,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<ScrollArea h={190} type="scroll">

View File

@ -102,18 +102,18 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
handleReaderToggle();
}
},
{
id: 'sign',
name: t("quickAccess.sign", "Sign"),
icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
onClick: () => {
setActiveButton('sign');
handleToolSelect('sign');
}
},
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),

View File

@ -20,7 +20,10 @@ import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
import { useSidebarContext } from '../../contexts/SidebarContext';
export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation();
const [isPanning, setIsPanning] = useState(false);
@ -178,7 +181,7 @@ export default function RightRail() {
}, [currentView]);
return (
<div className={`right-rail`} data-sidebar="right-rail">
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
<div className="right-rail-inner">
{topButtons.length > 0 && (
<>
@ -298,7 +301,10 @@ export default function RightRail() {
</Tooltip>
{/* Annotation Controls */}
<ViewerAnnotationControls currentView={currentView} />
<ViewerAnnotationControls
currentView={currentView}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
/>
</div>
<Divider className="right-rail-divider" />
</div>

View File

@ -8,6 +8,7 @@ import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import { BASE_PATH } from '../../constants/app';
import styles from './tooltip/Tooltip.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
export interface TooltipProps {
sidebarTooltip?: boolean;
@ -291,7 +292,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
left: coords.left,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
zIndex: 9999,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
color: 'var(--text-primary)',

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '../../../../contexts/PreferencesContext';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
@ -37,7 +38,7 @@ const GeneralSection: React.FC = () => {
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val) => updatePreference('defaultToolPanelMode', val as any)}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },

View File

@ -3,6 +3,7 @@ import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text, TextInpu
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
import { useHotkeys } from '../../../../contexts/HotkeyContext';
import { ToolId } from '../../../../types/toolId';
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
import { bindingEquals, eventToBinding } from '../../../../utils/hotkeys';
@ -83,7 +84,10 @@ const HotkeysSection: React.FC = () => {
));
if (conflictEntry) {
const conflictTool = toolRegistry[conflictEntry[0]]?.name ?? conflictEntry[0];
const conflictKey = conflictEntry[0];
const conflictTool = (conflictKey in toolRegistry)
? toolRegistry[conflictKey as ToolId]?.name
: conflictKey;
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
return;
}
@ -114,7 +118,7 @@ const HotkeysSection: React.FC = () => {
</div>
<TextInput
placeholder="Search tools..."
placeholder={t('settings.hotkeys.searchPlaceholder', 'Search tools...')}
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
size="md"
@ -125,7 +129,7 @@ const HotkeysSection: React.FC = () => {
<Stack gap="md">
{filteredTools.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{searchQuery.trim() ? 'No tools found matching your search.' : 'No tools available.'}
{t('toolPicker.noToolsFound', 'No tools found')}
</Text>
) : (
filteredTools.map(([toolId, tool], index) => {

View File

@ -10,16 +10,14 @@ import { useFileState, useFileContext } from '../../../contexts/FileContext';
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { createProcessedFile } from '../../../contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
interface ViewerAnnotationControlsProps {
currentView: string;
disabled?: boolean;
}
export default function ViewerAnnotationControls({ currentView }: ViewerAnnotationControlsProps) {
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const { toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
@ -53,7 +51,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || disableForFullscreen}
disabled={disabled || viewerContext?.isAnnotationMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@ -97,7 +95,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer' || disableForFullscreen}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
@ -139,7 +137,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer' || disableForFullscreen}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
@ -196,7 +194,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer' || disableForFullscreen}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>

View File

@ -1,22 +1,21 @@
import React, { useMemo } from 'react';
import { ActionIcon, Text } from '@mantine/core';
import { Tooltip } from '../shared/Tooltip';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ToolRegistryEntry, getSubcategoryLabel, getSubcategoryColor, getSubcategoryIcon } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import { useToolSections } from '../../hooks/useToolSections';
import NoToolsFound from './shared/NoToolsFound';
import { useHotkeys } from '../../contexts/HotkeyContext';
import HotkeyDisplay from '../hotkeys/HotkeyDisplay';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import StarBorderRoundedIcon from '@mui/icons-material/StarBorderRounded';
import ThumbUpRoundedIcon from '@mui/icons-material/ThumbUpRounded';
import Badge from '../shared/Badge';
import './ToolPanel.css';
import DetailedToolItem from './fullscreen/DetailedToolItem';
import CompactToolItem from './fullscreen/CompactToolItem';
import { useFavoriteToolItems } from '../../hooks/tools/useFavoriteToolItems';
interface FullscreenToolListProps {
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
searchQuery: string;
showDescriptions: boolean;
selectedToolKey: string | null;
@ -29,33 +28,25 @@ const FullscreenToolList = ({
searchQuery,
showDescriptions,
selectedToolKey,
matchedTextMap,
matchedTextMap: _matchedTextMap,
onSelect,
}: FullscreenToolListProps) => {
const { t } = useTranslation();
const { hotkeys } = useHotkeys();
const { toolRegistry, favoriteTools, toggleFavorite, isFavorite } = useToolWorkflow();
const { toolRegistry, favoriteTools } = useToolWorkflow();
const { sections, searchGroups } = useToolSections(filteredTools, searchQuery);
const tooltipPortalTarget = typeof document !== 'undefined' ? document.body : undefined;
const favoriteToolItems = useMemo(() => {
return favoriteTools
.map((toolId) => {
const tool = toolRegistry[toolId];
return tool ? { id: toolId, tool } : null;
})
.filter(Boolean);
}, [favoriteTools, toolRegistry]);
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const quickSection = useMemo(() => sections.find(section => section.key === 'quick'), [sections]);
const recommendedItems = useMemo(() => {
if (!quickSection) return [] as Array<{ id: string, tool: ToolRegistryEntry }>;
const items: Array<{ id: string, tool: ToolRegistryEntry }> = [];
quickSection.subcategories.forEach(sc => sc.tools.forEach(t => items.push(t)));
return items.slice(0, 5);
return items;
}, [quickSection]);
// Show recommended/favorites section only when not searching
@ -84,50 +75,12 @@ const FullscreenToolList = ({
? 'tool-panel__fullscreen-groups tool-panel__fullscreen-groups--detailed'
: 'tool-panel__fullscreen-groups tool-panel__fullscreen-groups--compact';
const getItemClasses = (isDetailed: boolean) => {
const base = isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
return base;
};
const getIconBackground = (categoryColor: string, isDetailed: boolean) => {
const baseColor = isDetailed ? 'var(--fullscreen-bg-icon-detailed)' : 'var(--fullscreen-bg-icon-compact)';
const blend1 = isDetailed ? '18%' : '15%';
const blend2 = isDetailed ? '8%' : '6%';
return `linear-gradient(135deg,
color-mix(in srgb, ${categoryColor} ${blend1}, ${baseColor}),
color-mix(in srgb, ${categoryColor} ${blend2}, ${baseColor})
)`;
};
const getIconStyle = () => {
return {};
};
// Helper function to render a tool item
const renderToolItem = (id: string, tool: ToolRegistryEntry) => {
const matchedText = matchedTextMap.get(id);
const renderToolItem = (id: ToolId, tool: ToolRegistryEntry) => {
const isSelected = selectedToolKey === id;
const isDisabled = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const binding = hotkeys[id];
const isFav = isFavorite(id as ToolId);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
let iconNode: React.ReactNode = null;
if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) {
const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>;
iconNode = React.cloneElement(element, {
style: {
...(element.props.style || {}),
fontSize: showDescriptions ? '1.75rem' : '1.5rem',
},
});
} else {
iconNode = tool.icon;
}
const handleClick = () => {
if (isDisabled) return;
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') return;
if (tool.link) {
window.open(tool.link, '_blank', 'noopener,noreferrer');
return;
@ -135,161 +88,14 @@ const FullscreenToolList = ({
onSelect(id as ToolId);
};
const handleStarClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite(id as ToolId);
};
// Detailed view
if (showDescriptions) {
const iconBg = getIconBackground(categoryColor, true);
const iconClasses = 'tool-panel__fullscreen-icon';
return (
<button
key={id}
type="button"
className={`tool-panel__fullscreen-item ${getItemClasses(true)} ${isSelected ? 'tool-panel__fullscreen-item--selected' : ''} tool-panel__fullscreen-item--with-star`}
onClick={handleClick}
aria-disabled={isDisabled}
disabled={isDisabled}
>
{tool.icon ? (
<span
className={iconClasses}
aria-hidden
style={{
background: iconBg,
...getIconStyle(),
}}
>
{iconNode}
</span>
) : null}
<span className="tool-panel__fullscreen-body">
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
{tool.description}
</Text>
{binding && (
<div className="tool-panel__fullscreen-shortcut">
<span style={{ color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} size="sm" />
</div>
)}
{matchedText && (
<Text size="xs" c="dimmed" className="tool-panel__fullscreen-match">
{t('toolPanel.fullscreen.matchedSynonym', 'Matches "{{text}}"', { text: matchedText })}
</Text>
)}
</span>
{!isDisabled && (
<ActionIcon
variant="subtle"
radius="xl"
size="sm"
onClick={handleStarClick}
className="tool-panel__fullscreen-star"
aria-label={isFav ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
{isFav ? (
<StarRoundedIcon fontSize="small" style={{ color: '#FFC107' }} />
) : (
<StarBorderRoundedIcon fontSize="small" />
)}
</ActionIcon>
)}
</button>
return (
<DetailedToolItem key={id} id={id} tool={tool} isSelected={isSelected} onClick={handleClick} />
);
}
// Compact view
const iconBg = getIconBackground(categoryColor, false);
const iconClasses = 'tool-panel__fullscreen-list-icon';
const compactButton = (
<button
key={id}
type="button"
className={`tool-panel__fullscreen-list-item ${getItemClasses(false)} ${isSelected ? 'tool-panel__fullscreen-list-item--selected' : ''} ${!isDisabled ? 'tool-panel__fullscreen-list-item--with-star' : ''}`}
onClick={handleClick}
aria-disabled={isDisabled}
disabled={isDisabled}
>
{tool.icon ? (
<span
className={iconClasses}
aria-hidden
style={{
background: iconBg,
...getIconStyle(),
}}
>
{iconNode}
</span>
) : null}
<span className="tool-panel__fullscreen-list-body">
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
{matchedText && (
<Text size="xs" c="dimmed" className="tool-panel__fullscreen-match">
{t('toolPanel.fullscreen.matchedSynonym', 'Matches "{{text}}"', { text: matchedText})}
</Text>
)}
</span>
{!isDisabled && (
<ActionIcon
variant="subtle"
radius="xl"
size="xs"
onClick={handleStarClick}
className="tool-panel__fullscreen-star-compact"
aria-label={isFav ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
{isFav ? (
<StarRoundedIcon fontSize="inherit" style={{ color: '#FFC107', fontSize: '1rem' }} />
) : (
<StarBorderRoundedIcon fontSize="inherit" style={{ fontSize: '1rem' }} />
)}
</ActionIcon>
)}
</button>
);
const tooltipContent = isDisabled
? (
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
{binding && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} />
</div>
)}
</div>
);
return (
<Tooltip
key={id}
content={tooltipContent}
position="top"
portalTarget={tooltipPortalTarget}
arrow
delay={80}
>
{compactButton}
</Tooltip>
<CompactToolItem key={id} id={id} tool={tool} isSelected={isSelected} onClick={handleClick} tooltipPortalTarget={tooltipPortalTarget} />
);
};
@ -329,11 +135,11 @@ const FullscreenToolList = ({
</header>
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{favoriteToolItems.map((item: any) => renderToolItem(item.id, item.tool))}
{favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{favoriteToolItems.map((item: any) => renderToolItem(item.id, item.tool))}
{favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))}
</div>
)}
</section>
@ -428,11 +234,11 @@ const FullscreenToolList = ({
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{tools.map(({ id, tool }) => renderToolItem(id, tool))}
{tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{tools.map(({ id, tool }) => renderToolItem(id, tool))}
{tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))}
</div>
)}
</section>

View File

@ -6,14 +6,15 @@ import ToolSearch from './toolPicker/ToolSearch';
import FullscreenToolList from './FullscreenToolList';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import { useFocusTrap } from '../../hooks/tools/useFocusTrap';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { BASE_PATH } from '../../constants/app';
import './ToolPanel.css';
import { ToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry';
interface FullscreenToolSurfaceProps {
searchQuery: string;
toolRegistry: Record<string, ToolRegistryEntry>;
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
selectedToolKey: string | null;
showDescriptions: boolean;
matchedTextMap: Map<string, string>;
@ -22,12 +23,7 @@ interface FullscreenToolSurfaceProps {
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
toggleLabel: string;
geometry: {
left: number;
top: number;
width: number;
height: number;
} | null;
geometry: ToolPanelGeometry | null;
}
const FullscreenToolSurface = ({
@ -69,10 +65,16 @@ const FullscreenToolSurface = ({
}
setIsExiting(true);
setTimeout(() => {
const el = surfaceRef.current;
if (!el) {
onExitFullscreenMode();
}, 220); // Match animation duration (0.22s)
};
return;
}
// Rely on CSS animation end rather than duplicating timing in JS
el.addEventListener('animationend', () => {
onExitFullscreenMode();
}, { once: true });
};
const style = geometry

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next';
import { useToolSections } from '../../hooks/useToolSections';
@ -9,7 +10,7 @@ import NoToolsFound from './shared/NoToolsFound';
import "./toolPicker/ToolPicker.css";
interface SearchResultsProps {
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
onSelect: (id: string) => void;
searchQuery?: string;
}
@ -48,14 +49,14 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect,
) : undefined;
return (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
matchedSynonym={matchedSynonym}
/>
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
matchedSynonym={matchedSynonym}
/>
);
})}
</Stack>

View File

@ -76,7 +76,7 @@
position: fixed;
display: flex;
pointer-events: none;
z-index: 1200;
z-index: var(--z-fullscreen-surface);
top: 0;
left: 0;
width: 0;
@ -101,11 +101,14 @@
box-shadow: none;
backdrop-filter: blur(18px);
overflow: hidden;
animation: tool-panel-fullscreen-slide-in 0.28s ease forwards;
/* Shared animation durations for JS + CSS (sourced from theme.css) */
--fullscreen-anim-in-duration: var(--fullscreen-anim-duration-in);
--fullscreen-anim-out-duration: var(--fullscreen-anim-duration-out);
animation: tool-panel-fullscreen-slide-in var(--fullscreen-anim-in-duration) ease forwards;
}
.tool-panel__fullscreen-surface-inner--exiting {
animation: tool-panel-fullscreen-slide-out 0.22s ease forwards;
animation: tool-panel-fullscreen-slide-out var(--fullscreen-anim-out-duration) ease forwards;
}
.tool-panel__fullscreen-header {
@ -340,7 +343,7 @@
.tool-panel__fullscreen-icon svg {
font-size: 1.65rem;
position: relative;
z-index: 1;
z-index: var(--z-fullscreen-icon-svg);
}
.tool-panel__fullscreen-body {

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
@ -15,9 +15,10 @@ import DashboardCustomizeRoundedIcon from '@mui/icons-material/DashboardCustomiz
import { useTranslation } from 'react-i18next';
import FullscreenToolSurface from './FullscreenToolSurface';
import { useToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry';
import { useLocalStorageState } from '../../hooks/tools/useLocalStorageState';
import { useLocalStorageState } from '../../hooks/tools/useJsonLocalStorageState';
import { useRightRail } from '../../contexts/RightRailContext';
import './ToolPanel.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
// No props needed - component uses context
@ -25,7 +26,7 @@ export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef } = sidebarRefs;
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
const isMobile = useMediaQuery('(max-width: 1024px)');
const {
@ -52,7 +53,7 @@ export default function ToolPanel() {
// Disable right rail buttons when fullscreen mode is active
React.useEffect(() => {
useEffect(() => {
setAllRightRailButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllRightRailButtonsDisabled]);
@ -62,6 +63,7 @@ export default function ToolPanel() {
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
rightRailRef,
});
const toggleLabel = isFullscreenMode
@ -141,7 +143,7 @@ export default function ToolPanel() {
withArrow
styles={{
tooltip: {
zIndex: 1400, // Higher than fullscreen surface
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}
}}
>

View File

@ -2,50 +2,33 @@ import { useEffect, useState } from 'react';
import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { TOOL_PANEL_MODE_STORAGE_KEY } from '../../contexts/toolWorkflow/state';
import './ToolPanelModePrompt.css';
import { useToolPanelModePreference } from '../../hooks/useToolPanelModePreference';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
type ToolPanelModeOption = 'sidebar' | 'fullscreen';
const PROMPT_SEEN_KEY = 'toolPanelModePromptSeen';
// type moved to hook
const ToolPanelModePrompt = () => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const [opened, setOpened] = useState(false);
const [hydrated, setHydrated] = useState(false);
const { hydrated, shouldShowPrompt, markPromptSeen, setPreferredMode } = useToolPanelModePreference();
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const hasSeenPrompt = window.localStorage.getItem(PROMPT_SEEN_KEY);
const storedPreference = window.localStorage.getItem(TOOL_PANEL_MODE_STORAGE_KEY);
if (!hasSeenPrompt && !storedPreference) {
if (shouldShowPrompt) {
setOpened(true);
}
}, [shouldShowPrompt]);
setHydrated(true);
}, []);
const persistSeen = () => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(PROMPT_SEEN_KEY, 'true');
};
const handleSelect = (mode: ToolPanelModeOption) => {
const handleSelect = (mode: ToolPanelMode) => {
setToolPanelMode(mode);
persistSeen();
setPreferredMode(mode);
markPromptSeen();
setOpened(false);
};
const handleDismiss = () => {
persistSeen();
markPromptSeen();
setOpened(false);
};

View File

@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
import "./toolPicker/ToolPicker.css";
import { useToolSections } from "../../hooks/useToolSections";
import type { SubcategoryGroup } from "../../hooks/useToolSections";
import { useFavoriteToolItems } from "../../hooks/tools/useFavoriteToolItems";
import NoToolsFound from "./shared/NoToolsFound";
import { renderToolButtons } from "./shared/renderToolButtons";
import Badge from "../shared/Badge";
@ -15,7 +17,7 @@ import { ToolId } from "../../types/toolId";
interface ToolPickerProps {
selectedToolKey: string | null;
onSelect: (id: string) => void;
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
isSearching?: boolean;
}
@ -68,16 +70,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow();
const favoriteToolItems = useMemo(() => {
return favoriteTools
.map((toolId) => {
const tool = (toolRegistry as any)[toolId as ToolId] as ToolRegistryEntry | undefined;
return tool ? { id: toolId as string, tool } : null;
})
.filter(Boolean)
// Only include ready tools (component or link) and navigational exceptions
.filter((item: any) => item && (item.tool.component || item.tool.link || item.id === 'read' || item.id === 'multiTool')) as Array<{ id: string; tool: ToolRegistryEntry }>;
}, [favoriteTools, toolRegistry]);
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const quickSection = useMemo(
() => visibleSections.find(s => s.key === 'quick'),
@ -87,8 +80,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
const recommendedItems = useMemo(() => {
if (!quickSection) return [] as Array<{ id: string; tool: ToolRegistryEntry }>;
const items: Array<{ id: string; tool: ToolRegistryEntry }> = [];
quickSection.subcategories.forEach((sc: any) => sc.tools.forEach((toolEntry: any) => items.push(toolEntry)));
return items.slice(0, 5);
quickSection.subcategories.forEach((sc: SubcategoryGroup) => sc.tools.forEach((toolEntry) => items.push(toolEntry)));
return items;
}, [quickSection]);
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
@ -113,7 +106,9 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
};
// Build flat list by subcategory for search mode
const { searchGroups } = useToolSections(isSearching ? filteredTools : []);
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
return (
<Box
@ -243,7 +238,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map((sc: any) =>
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
)}
</Stack>

View File

@ -1,10 +1,11 @@
import { Suspense } from "react";
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
import { BaseToolProps } from "../../types/tool";
import { ToolId } from "../../types/toolId";
import ToolLoadingFallback from "./ToolLoadingFallback";
interface ToolRendererProps extends BaseToolProps {
selectedToolKey: string;
selectedToolKey: ToolId | string;
}
@ -16,7 +17,9 @@ const ToolRenderer = ({
}: ToolRendererProps) => {
// Get the tool from context (instead of direct hook call)
const { toolRegistry } = useToolWorkflow();
const selectedTool = toolRegistry[selectedToolKey];
const selectedTool = (selectedToolKey in toolRegistry)
? toolRegistry[selectedToolKey as ToolId]
: undefined;
// Handle tools that only work in workbenches (read, multiTool)
if (selectedTool && !selectedTool.component && selectedTool.workbench) {

View File

@ -8,6 +8,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Tooltip } from '../../shared/Tooltip';
import { ToolIcon } from '../../shared/ToolIcon';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from 'src/types/toolId';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
@ -31,7 +32,7 @@ interface AutomationEntryProps {
/** Copy handler (for suggested automations) */
onCopy?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Record<string, ToolRegistryEntry>;
toolRegistry?: Record<ToolId, ToolRegistryEntry>;
}
export default function AutomationEntry({
@ -55,8 +56,8 @@ export default function AutomationEntry({
// Helper function to resolve tool display names
const getToolDisplayName = (operation: string): string => {
if (toolRegistry?.[operation]?.name) {
return toolRegistry[operation].name;
if (toolRegistry?.[operation as ToolId]?.name) {
return toolRegistry[operation as ToolId].name;
}
// Fallback to translation or operation key
return t(`${operation}.title`, operation);

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
@ -30,7 +30,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
React.useEffect(() => {
useEffect(() => {
if (automation?.operations) {
const steps = automation.operations.map((op: any, index: number) => {
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
@ -47,7 +47,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
}, [automation, toolRegistry]);
// Cleanup when component unmounts
React.useEffect(() => {
useEffect(() => {
return () => {
// Reset progress state when component unmounts
setExecutionSteps([]);

View File

@ -7,6 +7,7 @@ import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSugges
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
import { iconMap } from './iconMap';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from '../../../types/toolId';
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
@ -15,7 +16,7 @@ interface AutomationSelectionProps {
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
}
export default function AutomationSelection({

View File

@ -6,12 +6,13 @@ import CloseIcon from "@mui/icons-material/Close";
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import { AutomationTool } from "../../../types/automation";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { ToolId } from "../../../types/toolId";
import ToolSelector from "./ToolSelector";
import AutomationEntry from "./AutomationEntry";
interface ToolListProps {
tools: AutomationTool[];
toolRegistry: Record<string, ToolRegistryEntry>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
onToolRemove: (index: number) => void;
onToolConfigure: (index: number) => void;
@ -34,7 +35,7 @@ export default function ToolList({
const handleToolSelect = (index: number, newOperation: string) => {
const defaultParams = getToolDefaultParameters(newOperation);
const toolEntry = toolRegistry[newOperation];
const toolEntry = toolRegistry[newOperation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;

View File

@ -6,11 +6,12 @@ import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
import ToolButton from '../toolPicker/ToolButton';
import { ToolId } from '../../../types/toolId';
interface ToolSelectorProps {
onSelect: (toolKey: string) => void;
excludeTools?: string[];
toolRegistry: Record<string, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
toolRegistry: Record<ToolId, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
selectedValue?: string; // For showing current selection when editing existing tool
placeholder?: string; // Custom placeholder text
}
@ -53,16 +54,16 @@ export default function ToolSelector({
// Create filtered tool registry for ToolSearch
const filteredToolRegistry = useMemo(() => {
const registry: Record<string, ToolRegistryEntry> = {};
const registry: Record<ToolId, ToolRegistryEntry> = {} as Record<ToolId, ToolRegistryEntry>;
baseFilteredTools.forEach(([key, tool]) => {
registry[key] = tool;
registry[key as ToolId] = tool;
});
return registry;
}, [baseFilteredTools]);
// Transform filteredTools to the expected format for useToolSections
const transformedFilteredTools = useMemo(() => {
return filteredTools.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
return filteredTools.map(([id, tool]) => ({ item: [id as ToolId, tool] as [ToolId, ToolRegistryEntry] }));
}, [filteredTools]);
// Use the same tool sections logic as the main ToolPicker
@ -88,7 +89,7 @@ export default function ToolSelector({
}
// Find the "all" section which contains all tools without duplicates
const allSection = sections.find(s => (s as any).key === 'all');
const allSection = sections.find(s => s.key === 'all');
return allSection?.subcategories || [];
}, [isSearching, searchGroups, sections, baseFilteredTools]);
@ -100,7 +101,7 @@ export default function ToolSelector({
const renderedTools = useMemo(() =>
displayGroups.map((subcategory) =>
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching, true)
renderToolButtons(t, subcategory as any, null, handleToolSelect, !isSearching, true)
), [displayGroups, handleToolSelect, isSearching, t]
);
@ -142,8 +143,8 @@ export default function ToolSelector({
// Get display value for selected tool
const getDisplayValue = () => {
if (selectedValue && toolRegistry[selectedValue]) {
return toolRegistry[selectedValue].name;
if (selectedValue && toolRegistry[selectedValue as ToolId]) {
return toolRegistry[selectedValue as ToolId].name;
}
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
};
@ -152,11 +153,11 @@ export default function ToolSelector({
<div ref={containerRef} className='rounded-xl'>
{/* Always show the target - either selected tool or search input */}
{selectedValue && toolRegistry[selectedValue] && !opened ? (
{selectedValue && toolRegistry[selectedValue as ToolId] && !opened ? (
// Show selected tool in AutomationEntry style when tool is selected and dropdown closed
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
borderRadius: "var(--mantine-radius-lg)" }}>
<ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
<ToolButton id='tool' tool={toolRegistry[selectedValue as ToolId]} isSelected={false}
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
</div>
) : (

View File

@ -0,0 +1,115 @@
import React from 'react';
import { ActionIcon, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../shared/Tooltip';
import HotkeyDisplay from '../../hotkeys/HotkeyDisplay';
import { ToolRegistryEntry, getSubcategoryColor } from '../../../data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from './shared';
interface CompactToolItemProps {
id: string;
tool: ToolRegistryEntry;
isSelected: boolean;
onClick: () => void;
tooltipPortalTarget?: HTMLElement | undefined;
}
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, false);
const iconClasses = 'tool-panel__fullscreen-list-icon';
let iconNode: React.ReactNode = null;
if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) {
const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>;
iconNode = React.cloneElement(element, {
style: {
...(element.props.style || {}),
fontSize: '1.5rem',
},
});
} else {
iconNode = tool.icon;
}
const compactButton = (
<button
type="button"
className={`tool-panel__fullscreen-list-item ${getItemClasses(false)} ${isSelected ? 'tool-panel__fullscreen-list-item--selected' : ''} ${!disabled ? 'tool-panel__fullscreen-list-item--with-star' : ''}`}
onClick={onClick}
aria-disabled={disabled}
disabled={disabled}
>
{tool.icon ? (
<span
className={iconClasses}
aria-hidden
style={{
background: iconBg,
...getIconStyle(),
}}
>
{iconNode}
</span>
) : null}
<span className="tool-panel__fullscreen-list-body">
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
</span>
{!disabled && (
<ActionIcon
variant="subtle"
radius="xl"
size="xs"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); toggleFavorite(); }}
className="tool-panel__fullscreen-star-compact"
aria-label={isFav ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
{/* Star icons kept inline to avoid new dependency here */}
{isFav ? (
<span className="material-icons" style={{ color: 'var(--special-color-favorites)', fontSize: '16px' }}>star</span>
) : (
<span className="material-icons" style={{ fontSize: '16px' }}>star_border</span>
)}
</ActionIcon>
)}
</button>
);
const tooltipContent = disabled
? (
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
{binding && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} />
</div>
)}
</div>
);
return (
<Tooltip
content={tooltipContent}
position="top"
portalTarget={tooltipPortalTarget}
arrow
delay={80}
>
{compactButton}
</Tooltip>
);
};
export default CompactToolItem;

View File

@ -0,0 +1,95 @@
import React from 'react';
import { ActionIcon, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import HotkeyDisplay from '../../hotkeys/HotkeyDisplay';
import { ToolRegistryEntry, getSubcategoryColor } from '../../../data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from './shared';
interface DetailedToolItemProps {
id: string;
tool: ToolRegistryEntry;
isSelected: boolean;
onClick: () => void;
}
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, true);
const iconClasses = 'tool-panel__fullscreen-icon';
let iconNode: React.ReactNode = null;
if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) {
const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>;
iconNode = React.cloneElement(element, {
style: {
...(element.props.style || {}),
fontSize: '1.75rem',
},
});
} else {
iconNode = tool.icon;
}
return (
<button
type="button"
className={`tool-panel__fullscreen-item ${getItemClasses(true)} ${isSelected ? 'tool-panel__fullscreen-item--selected' : ''} tool-panel__fullscreen-item--with-star`}
onClick={onClick}
aria-disabled={disabled}
disabled={disabled}
>
{tool.icon ? (
<span
className={iconClasses}
aria-hidden
style={{
background: iconBg,
...getIconStyle(),
}}
>
{iconNode}
</span>
) : null}
<span className="tool-panel__fullscreen-body">
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
{tool.description}
</Text>
{binding && (
<div className="tool-panel__fullscreen-shortcut">
<span style={{ color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} size="sm" />
</div>
)}
</span>
{!disabled && (
<ActionIcon
variant="subtle"
radius="xl"
size="sm"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); toggleFavorite(); }}
className="tool-panel__fullscreen-star"
aria-label={isFav ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
{/* Star icons kept inline to avoid new dependency here */}
{isFav ? (
<span className="material-icons" style={{ color: 'var(--special-color-favorites)', fontSize: '20px' }}>star</span>
) : (
<span className="material-icons" style={{ fontSize: '20px' }}>star_border</span>
)}
</ActionIcon>
)}
</button>
);
};
export default DetailedToolItem;

View File

@ -0,0 +1,45 @@
import { useHotkeys } from '../../../contexts/HotkeyContext';
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from '../../../types/toolId';
export const getItemClasses = (isDetailed: boolean): string => {
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
};
export const getIconBackground = (categoryColor: string, isDetailed: boolean): string => {
const baseColor = isDetailed ? 'var(--fullscreen-bg-icon-detailed)' : 'var(--fullscreen-bg-icon-compact)';
const blend1 = isDetailed ? '18%' : '15%';
const blend2 = isDetailed ? '8%' : '6%';
return `linear-gradient(135deg,
color-mix(in srgb, ${categoryColor} ${blend1}, ${baseColor}),
color-mix(in srgb, ${categoryColor} ${blend2}, ${baseColor})
)`;
};
export const getIconStyle = (): Record<string, string> => {
return {};
};
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
};
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
const { hotkeys } = useHotkeys();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const isFav = isFavorite(id as ToolId);
const binding = hotkeys[id];
const disabled = isToolDisabled(id, tool);
return {
binding,
isFav,
toggleFavorite: () => toggleFavorite(id as ToolId),
disabled,
};
}

View File

@ -31,21 +31,21 @@ export const renderToolButtons = (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<div>
{subcategory.tools.map(({ id, tool }, _index) => {
const matchedSynonym = matchedTextMap.get(id);
return (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
disableNavigation={disableNavigation}
matchedSynonym={matchedSynonym}
hasStars={hasStars}
/>
);
{subcategory.tools.map(({ id, tool }) => {
const matchedSynonym = matchedTextMap.get(id);
return (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
disableNavigation={disableNavigation}
matchedSynonym={matchedSynonym}
hasStars={hasStars}
/>
);
})}
</div>
</Box>

View File

@ -0,0 +1,40 @@
import React from "react";
import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import StarBorderRoundedIcon from '@mui/icons-material/StarBorderRounded';
interface FavoriteStarProps {
isFavorite: boolean;
onToggle: () => void;
className?: string;
size?: "xs" | "sm" | "md" | "lg" | "xl";
}
const FavoriteStar: React.FC<FavoriteStarProps> = ({ isFavorite, onToggle, className, size = "xs" }) => {
const { t } = useTranslation();
return (
<ActionIcon
variant="subtle"
radius="xl"
size={size}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onToggle();
}}
className={className}
aria-label={isFavorite ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
{isFavorite ? (
<StarRoundedIcon fontSize="inherit" style={{ color: 'var(--special-color-favorites)', fontSize: '1rem' }} />
) : (
<StarBorderRoundedIcon fontSize="inherit" style={{ fontSize: '1rem' }} />
)}
</ActionIcon>
);
};
export default FavoriteStar;

View File

@ -1,5 +1,5 @@
import React from "react";
import { ActionIcon, Button } from "@mantine/core";
import { Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Tooltip } from "../../shared/Tooltip";
import { ToolIcon } from "../../shared/ToolIcon";
@ -9,8 +9,7 @@ import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
import FitText from "../../shared/FitText";
import { useHotkeys } from "../../../contexts/HotkeyContext";
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import StarBorderRoundedIcon from '@mui/icons-material/StarBorderRounded';
import FavoriteStar from "./FavoriteStar";
import { useToolWorkflow } from "../../../contexts/ToolWorkflowContext";
import { ToolId } from "../../../types/toolId";
@ -171,23 +170,12 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
);
const star = hasStars && !isUnavailable ? (
<ActionIcon
variant="subtle"
radius="xl"
size="xs"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite(id as ToolId);
}}
<FavoriteStar
isFavorite={fav}
onToggle={() => toggleFavorite(id as ToolId)}
className="tool-button-star"
aria-label={fav ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
{fav ? (
<StarRoundedIcon fontSize="inherit" style={{ color: '#FFC107', fontSize: '1rem' }} />
) : (
<StarBorderRoundedIcon fontSize="inherit" style={{ fontSize: '1rem' }} />
)}
</ActionIcon>
size="xs"
/>
) : null;
return (

View File

@ -82,7 +82,7 @@
right: 0.35rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1; /* lower than sticky section headers */
z-index: var(--z-toolpicker-star); /* lower than sticky section headers */
}
.tool-button-container:hover .tool-button-star {

View File

@ -6,12 +6,13 @@ import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { TextInput } from "../../shared/TextInput";
import "./ToolPicker.css";
import { rankByFuzzy, idToWords } from "../../../utils/fuzzySearch";
import { ToolId } from "src/types/toolId";
interface ToolSearchProps {
value: string;
onChange: (value: string) => void;
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
onToolSelect?: (toolId: string) => void;
toolRegistry: Readonly<Record<ToolId, ToolRegistryEntry>>;
onToolSelect?: (toolId: ToolId) => void;
mode: "filter" | "dropdown" | "unstyled";
selectedToolKey?: string | null;
placeholder?: string;
@ -20,7 +21,7 @@ interface ToolSearchProps {
autoFocus?: boolean;
}
const ToolSearch: React.FC<ToolSearchProps> = ({
const ToolSearch = ({
value,
onChange,
toolRegistry,
@ -31,7 +32,7 @@ const ToolSearch: React.FC<ToolSearchProps> = ({
hideIcon = false,
onFocus,
autoFocus = false,
}) => {
}: ToolSearchProps) => {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
@ -126,7 +127,7 @@ const ToolSearch: React.FC<ToolSearchProps> = ({
key={id}
variant="subtle"
onClick={() => {
onToolSelect?.(id);
onToolSelect?.(id as ToolId);
setDropdownOpen(false);
}}
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}

View File

@ -93,7 +93,7 @@ const EmbedPdfViewerContent = ({
}, [previewFile, fileWithUrl]);
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
React.useEffect(() => {
useEffect(() => {
let accumulator = 0;
const handleWheel = (event: WheelEvent) => {
@ -127,7 +127,7 @@ const EmbedPdfViewerContent = ({
}, [zoomActions]);
// Handle keyboard zoom shortcuts
React.useEffect(() => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isViewerHovered) return;

View File

@ -92,7 +92,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const next: Record<string, HotkeyBinding> = {};
let changed = false;
Object.entries(prev).forEach(([toolId, binding]) => {
if (toolRegistry[toolId]) {
if (toolRegistry[toolId as ToolId]) {
next[toolId] = binding;
} else {
changed = true;

View File

@ -7,6 +7,7 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
// All sidebar state management
const quickAccessRef = useRef<HTMLDivElement>(null);
const toolPanelRef = useRef<HTMLDivElement>(null);
const rightRailRef = useRef<HTMLDivElement>(null);
const [sidebarsVisible, setSidebarsVisible] = useState(true);
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
@ -21,7 +22,8 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
const sidebarRefs: SidebarRefs = useMemo(() => ({
quickAccessRef,
toolPanelRef,
}), [quickAccessRef, toolPanelRef]);
rightRailRef,
}), [quickAccessRef, toolPanelRef, rightRailRef]);
const contextValue: SidebarContextValue = useMemo(() => ({
sidebarState,

View File

@ -11,14 +11,14 @@ import { useNavigationActions, useNavigationState } from './NavigationContext';
import { ToolId, isValidToolId } from '../types/toolId';
import { getDefaultWorkbench } from '../types/workbench';
import { filterToolRegistryByQuery } from '../utils/toolSearch';
import { useToolHistory } from '../hooks/tools/useToolHistory';
import { useToolHistory } from '../hooks/tools/useUserToolActivity';
import {
ToolWorkflowState,
TOOL_PANEL_MODE_STORAGE_KEY,
createInitialState,
toolWorkflowReducer,
ToolPanelMode,
} from './toolWorkflow/state';
} from './toolWorkflow/toolWorkflowState';
import { usePreferences } from './PreferencesContext';
// State interface
@ -29,7 +29,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: string | null;
selectedTool: ToolRegistryEntry | null;
toolRegistry: Record<string, ToolRegistryEntry>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null;
// UI Actions
@ -56,13 +56,11 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
handleReaderToggle: () => void;
// Computed values
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
isPanelVisible: boolean;
// Tool History
recentTools: ToolId[];
favoriteTools: ToolId[];
addToRecent: (toolId: ToolId) => void;
toggleFavorite: (toolId: ToolId) => void;
isFavorite: (toolId: ToolId) => boolean;
}
@ -93,9 +91,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Tool history hook
const {
recentTools,
favoriteTools,
addToRecent,
toggleFavorite,
isFavorite,
} = useToolHistory();
@ -180,9 +176,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: ToolId) => {
// Track tool usage in recent history
addToRecent(toolId);
// Handle read tool selection - should behave exactly like QuickAccessBar read button
if (toolId === 'read') {
setReaderMode(true);
@ -218,7 +211,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery('');
setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools
}, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery, addToRecent]);
}, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
@ -276,9 +269,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
isPanelVisible,
// Tool History
recentTools,
favoriteTools,
addToRecent,
toggleFavorite,
isFavorite,
}), [
@ -302,9 +293,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
handleReaderToggle,
filteredTools,
isPanelVisible,
recentTools,
favoriteTools,
addToRecent,
toggleFavorite,
isFavorite,
]);

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { ToolId } from '../../types/toolId';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
export function useFavoriteToolItems(
favoriteTools: ToolId[],
toolRegistry: Readonly<Record<ToolId, ToolRegistryEntry>>
): Array<{ id: ToolId; tool: ToolRegistryEntry }> {
return useMemo(() => {
return favoriteTools
.map((toolId) => {
const tool = toolRegistry[toolId as ToolId];
return tool ? { id: toolId as ToolId, tool } : null;
})
.filter((x): x is { id: ToolId; tool: ToolRegistryEntry } => x !== null)
.filter(({ id, tool }) => Boolean(tool.component) || Boolean(tool.link) || id === 'read' || id === 'multiTool');
}, [favoriteTools, toolRegistry]);
}

View File

@ -1,4 +1,4 @@
import { useLayoutEffect, useState, RefObject } from 'react';
import { useLayoutEffect, useState, RefObject, useRef } from 'react';
export interface ToolPanelGeometry {
left: number;
@ -11,14 +11,17 @@ interface UseToolPanelGeometryOptions {
enabled: boolean;
toolPanelRef: RefObject<HTMLDivElement | null>;
quickAccessRef: RefObject<HTMLDivElement | null>;
rightRailRef?: RefObject<HTMLDivElement | null>;
}
export function useToolPanelGeometry({
enabled,
toolPanelRef,
quickAccessRef,
rightRailRef,
}: UseToolPanelGeometryOptions) {
const [geometry, setGeometry] = useState<ToolPanelGeometry | null>(null);
const scheduleUpdateRef = useRef<() => void>(() => {});
useLayoutEffect(() => {
if (!enabled) {
@ -32,52 +35,41 @@ export function useToolPanelGeometry({
return;
}
const rightRailEl = () => document.querySelector('[data-sidebar="right-rail"]') as HTMLElement | null;
const rightRailEl = () => (rightRailRef?.current ?? null);
let timeoutId: number | null = null;
let rafId: number | null = null;
const updateGeometry = () => {
// Debounce: clear any pending update
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
// Schedule update after 150ms of inactivity
timeoutId = window.setTimeout(() => {
const rect = panelEl.getBoundingClientRect();
const rail = rightRailEl();
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left: rect.left,
top: rect.top,
width,
height,
});
timeoutId = null;
}, 150);
const computeAndSetGeometry = () => {
const rect = panelEl.getBoundingClientRect();
const rail = rightRailEl();
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left: rect.left,
top: rect.top,
width,
height,
});
};
// Initial geometry calculation (no debounce)
const rect = panelEl.getBoundingClientRect();
const rail = rightRailEl();
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left: rect.left,
top: rect.top,
width,
height,
});
const scheduleUpdate = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
computeAndSetGeometry();
rafId = null;
});
};
scheduleUpdateRef.current = scheduleUpdate;
const handleResize = () => updateGeometry();
window.addEventListener('resize', handleResize);
// Initial geometry calculation (no debounce)
computeAndSetGeometry();
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => updateGeometry());
resizeObserver = new ResizeObserver(() => scheduleUpdate());
resizeObserver.observe(panelEl);
if (quickAccessRef.current) {
resizeObserver.observe(quickAccessRef.current);
@ -86,16 +78,42 @@ export function useToolPanelGeometry({
if (rail) {
resizeObserver.observe(rail);
}
// Observe root element to react to viewport-driven layout changes
if (document.documentElement) {
resizeObserver.observe(document.documentElement);
}
} else {
// Fallback for environments without ResizeObserver
const handleResize = () => scheduleUpdate();
window.addEventListener('resize', handleResize);
// Ensure cleanup of the fallback listener
resizeObserver = {
disconnect: () => window.removeEventListener('resize', handleResize),
} as unknown as ResizeObserver;
}
return () => {
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
window.removeEventListener('resize', handleResize);
scheduleUpdateRef.current = () => {};
resizeObserver?.disconnect();
};
}, [enabled, quickAccessRef, toolPanelRef]);
}, [enabled, quickAccessRef, toolPanelRef, rightRailRef]);
// Secondary effect: (re)attach observers when refs' .current become available later
useLayoutEffect(() => {
if (!enabled) return;
if (typeof ResizeObserver === 'undefined') return;
const qa = quickAccessRef.current;
const rail = rightRailRef?.current ?? null;
if (!qa && !rail) return;
const ro = new ResizeObserver(() => scheduleUpdateRef.current());
if (qa) ro.observe(qa);
if (rail) ro.observe(rail);
return () => ro.disconnect();
}, [enabled, quickAccessRef.current, rightRailRef?.current]);
return geometry;
}

View File

@ -3,7 +3,6 @@ import { ToolId } from '../../types/toolId';
const RECENT_TOOLS_KEY = 'stirlingpdf.recentTools';
const FAVORITE_TOOLS_KEY = 'stirlingpdf.favoriteTools';
const MAX_RECENT_TOOLS = 10;
export function useToolHistory() {
const [recentTools, setRecentTools] = useState<ToolId[]>([]);
@ -37,21 +36,6 @@ export function useToolHistory() {
}
}, []);
// Add a tool to recent history
const addToRecent = useCallback((toolId: ToolId) => {
if (typeof window === 'undefined') {
return;
}
setRecentTools((prev) => {
// Remove if already exists
const filtered = prev.filter((id) => id !== toolId);
// Add to front
const updated = [toolId, ...filtered].slice(0, MAX_RECENT_TOOLS);
window.localStorage.setItem(RECENT_TOOLS_KEY, JSON.stringify(updated));
return updated;
});
}, []);
// Toggle favorite status
const toggleFavorite = useCallback((toolId: ToolId) => {
@ -80,7 +64,6 @@ export function useToolHistory() {
return {
recentTools,
favoriteTools,
addToRecent,
toggleFavorite,
isFavorite,
};

View File

@ -72,3 +72,5 @@ export function useFocusTrap(containerRef: RefObject<HTMLElement | null>, enable
};
}, [containerRef, enabled]);
}

View File

@ -4,11 +4,12 @@ import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { FileId } from '../types/file';
import { ToolId } from 'src/types/toolId';
interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: FileId[];
toolRegistry: Record<string, ToolRegistryEntry>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null;
}
@ -30,12 +31,12 @@ export const useToolManagement = (): ToolManagementResult => {
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const toolRegistry: Record<string, ToolRegistryEntry> = useMemo(() => {
const availableToolRegistry: Record<string, ToolRegistryEntry> = {};
const toolRegistry: Record<ToolId, ToolRegistryEntry> = useMemo(() => {
const availableToolRegistry: Record<ToolId, ToolRegistryEntry> = {} as Record<ToolId, ToolRegistryEntry>;
Object.keys(baseRegistry).forEach(toolKey => {
if (isToolAvailable(toolKey)) {
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
availableToolRegistry[toolKey] = {
availableToolRegistry[toolKey as ToolId] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
@ -46,7 +47,7 @@ export const useToolManagement = (): ToolManagementResult => {
}, [isToolAvailable, t, baseRegistry]);
const getSelectedTool = useCallback((toolKey: string | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null;
return toolKey ? toolRegistry[toolKey as ToolId] || null : null;
}, [toolRegistry]);
return {

View File

@ -0,0 +1,50 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TOOL_PANEL_MODE_STORAGE_KEY, ToolPanelMode } from '../contexts/toolWorkflow/toolWorkflowState';
const PROMPT_SEEN_KEY = 'toolPanelModePromptSeen';
export function useToolPanelModePreference() {
const [hydrated, setHydrated] = useState(false);
const getPreferredMode = useCallback((): ToolPanelMode | null => {
if (typeof window === 'undefined') return null;
const stored = window.localStorage.getItem(TOOL_PANEL_MODE_STORAGE_KEY);
return stored === 'sidebar' || stored === 'fullscreen' ? stored : null;
}, []);
const setPreferredMode = useCallback((mode: ToolPanelMode) => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, mode);
}, []);
const hasSeenPrompt = useCallback((): boolean => {
if (typeof window === 'undefined') return true;
return window.localStorage.getItem(PROMPT_SEEN_KEY) === 'true';
}, []);
const markPromptSeen = useCallback(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(PROMPT_SEEN_KEY, 'true');
}, []);
const shouldShowPrompt = useMemo(() => {
const seen = hasSeenPrompt();
const pref = getPreferredMode();
return !seen && !pref;
}, [getPreferredMode, hasSeenPrompt]);
useEffect(() => {
setHydrated(true);
}, []);
return {
hydrated,
getPreferredMode,
setPreferredMode,
hasSeenPrompt,
markPromptSeen,
shouldShowPrompt,
} as const;
}

View File

@ -2,9 +2,10 @@ import { useMemo } from 'react';
import { SUBCATEGORY_ORDER, SubcategoryId, ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy';
import { useTranslation } from 'react-i18next';
import { ToolId } from 'src/types/toolId';
type SubcategoryIdMap = {
[subcategoryId in SubcategoryId]: Array<{ id: string /* FIX ME: Should be ToolId */; tool: ToolRegistryEntry }>;
[subcategoryId in SubcategoryId]: Array<{ id: ToolId; tool: ToolRegistryEntry }>;
}
type GroupedTools = {
@ -14,7 +15,7 @@ type GroupedTools = {
export interface SubcategoryGroup {
subcategoryId: SubcategoryId;
tools: {
id: string /* FIX ME: Should be ToolId */;
id: ToolId;
tool: ToolRegistryEntry;
}[];
};
@ -28,7 +29,7 @@ export interface ToolSection {
};
export function useToolSections(
filteredTools: Array<{ item: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry]; matchedText?: string }>,
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>,
searchQuery?: string
) {
const { t } = useTranslation();
@ -114,7 +115,7 @@ export function useToolSections(
seen.add(toolId);
const sub = tool.subcategoryId;
if (!subMap[sub]) subMap[sub] = [];
subMap[sub].push({ id: toolId, tool });
subMap[sub].push({ id: toolId as ToolId, tool });
});
const entries = Object.entries(subMap);

View File

@ -1,9 +1,10 @@
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface UserPreferences {
autoUnzip: boolean;
autoUnzipFileLimit: number;
defaultToolPanelMode: 'sidebar' | 'fullscreen';
defaultToolPanelMode: ToolPanelMode;
}
export const DEFAULT_PREFERENCES: UserPreferences = {

View File

@ -1,6 +1,17 @@
/* CSS variables for Tailwind + Mantine integration */
:root {
/* Global animation durations */
--fullscreen-anim-duration-in: 0.28s;
--fullscreen-anim-duration-out: 0.22s;
/* Z-index constants (added in this PR) */
--z-over-fullscreen-surface: 1400;
--z-fullscreen-surface: 1200;
--z-fullscreen-favorite-star: 2;
--z-fullscreen-icon-svg: 1;
--z-toolpicker-star: 1;
/* Standard gray scale */
--gray-50: 249 250 251;
--gray-100: 243 244 246;

View File

@ -0,0 +1,8 @@
// Centralized z-index constants for new usages added in this branch.
// Keep values identical to their original inline usages.
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1400;
export const Z_INDEX_FULLSCREEN_SURFACE = 1200;
export const Z_INDEX_FILE_MANAGER_MODAL = 1300;

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
@ -48,7 +48,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
};
// Register reset function with the tool workflow context - only once on mount
React.useEffect(() => {
useEffect(() => {
const stableResetFunction = () => {
if (resetFunctionRef.current) {
resetFunctionRef.current();

View File

@ -7,6 +7,7 @@ export interface SidebarState {
export interface SidebarRefs {
quickAccessRef: React.RefObject<HTMLDivElement | null>;
toolPanelRef: React.RefObject<HTMLDivElement | null>;
rightRailRef: React.RefObject<HTMLDivElement | null>;
}
export interface SidebarInfo {

View File

@ -1,18 +1,19 @@
import { ToolId } from "src/types/toolId";
import { ToolRegistryEntry } from "../data/toolsTaxonomy";
import { scoreMatch, minScoreForQuery, normalizeForSearch } from "./fuzzySearch";
export interface RankedToolItem {
item: [string, ToolRegistryEntry];
item: [ToolId, ToolRegistryEntry];
matchedText?: string;
}
export function filterToolRegistryByQuery(
toolRegistry: Record<string, ToolRegistryEntry>,
toolRegistry: Record<ToolId, ToolRegistryEntry>,
query: string
): RankedToolItem[] {
const entries = Object.entries(toolRegistry);
if (!query.trim()) {
return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] }));
}
const nq = normalizeForSearch(query);
@ -78,21 +79,21 @@ export function filterToolRegistryByQuery(
const seen = new Set<string>();
const ordered: RankedToolItem[] = [];
const push = (id: string, tool: ToolRegistryEntry, matchedText?: string) => {
const push = (id: ToolId, tool: ToolRegistryEntry, matchedText?: string) => {
if (seen.has(id)) return;
seen.add(id);
ordered.push({ item: [id, tool], matchedText });
};
for (const { id, tool } of exactName) push(id, tool, tool.name);
for (const { id, tool, text } of exactSyn) push(id, tool, text);
for (const { id, tool, text } of fuzzyName) push(id, tool, text);
for (const { id, tool, text } of fuzzySyn) push(id, tool, text);
for (const { id, tool } of exactName) push(id as ToolId, tool, tool.name);
for (const { id, tool, text } of exactSyn) push(id as ToolId, tool, text);
for (const { id, tool, text } of fuzzyName) push(id as ToolId, tool, text);
for (const { id, tool, text } of fuzzySyn) push(id as ToolId, tool, text);
if (ordered.length > 0) return ordered;
// Fallback: return everything unchanged
return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] }));
}