feat: Implement quick icon selection workflow for improved UX

- Add hotkey 'i' to instantly open icon selector when node is selected
- Auto-focus search box when icon selector opens for immediate typing
- Display recently used icons (up to 12) with localStorage persistence
- Show most commonly used icons in current diagram when no recents
- Implement keyboard navigation with arrow keys and Enter to select
- Add double-click to select icon and close selector
- Visual hover indicators and real-time search filtering
- Reduce icon selection from 5+ clicks to 2-3 keystrokes

Closes #56
This commit is contained in:
Stan
2025-08-17 22:04:21 +01:00
parent 6d53f08317
commit 8576e300ec
5 changed files with 279 additions and 17 deletions

View File

@@ -10,14 +10,16 @@ interface Props {
icon: IconI;
onClick?: () => void;
onMouseDown?: () => void;
onDoubleClick?: () => void;
}
export const Icon = ({ icon, onClick, onMouseDown }: Props) => {
export const Icon = ({ icon, onClick, onMouseDown, onDoubleClick }: Props) => {
return (
<Button
variant="text"
onClick={onClick}
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
sx={{
userSelect: 'none'
}}

View File

@@ -1,29 +1,45 @@
import React from 'react';
import { Icon as IconI } from 'src/types';
import { Grid } from '@mui/material';
import { Grid, Box } from '@mui/material';
import { Icon } from './Icon';
interface Props {
icons: IconI[];
onMouseDown?: (icon: IconI) => void;
onClick?: (icon: IconI) => void;
onDoubleClick?: (icon: IconI) => void;
hoveredIndex?: number;
onHover?: (index: number) => void;
}
export const IconGrid = ({ icons, onMouseDown, onClick }: Props) => {
export const IconGrid = ({ icons, onMouseDown, onClick, onDoubleClick, hoveredIndex, onHover }: Props) => {
return (
<Grid container>
{icons.map((icon) => {
{icons.map((icon, index) => {
const isHovered = hoveredIndex === index;
return (
<Grid item xs={3} key={icon.id}>
<Icon
icon={icon}
onClick={() => {
onClick?.(icon);
<Box
sx={{
backgroundColor: isHovered ? 'action.hover' : 'transparent',
borderRadius: 1,
transition: 'background-color 0.2s'
}}
onMouseDown={() => {
onMouseDown?.(icon);
}}
/>
onMouseEnter={() => onHover?.(index)}
>
<Icon
icon={icon}
onClick={() => {
onClick?.(icon);
}}
onMouseDown={() => {
onMouseDown?.(icon);
}}
onDoubleClick={() => {
onDoubleClick?.(icon);
}}
/>
</Box>
</Grid>
);
})}

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { Box, Stack, Button, IconButton as MUIIconButton } from '@mui/material';
import {
ChevronRight as ChevronRightIcon,
@@ -15,6 +15,7 @@ import { ControlsContainer } from '../components/ControlsContainer';
import { Icons } from '../IconSelectionControls/Icons';
import { NodeSettings } from './NodeSettings/NodeSettings';
import { Section } from '../components/Section';
import { QuickIconSelector } from './QuickIconSelector';
interface Props {
id: string;
@@ -42,6 +43,18 @@ export const NodeControls = ({ id }: Props) => {
setMode(newMode);
}, []);
// Listen for quick icon change event (triggered by 'i' hotkey)
useEffect(() => {
const handleQuickIconChange = () => {
setMode('CHANGE_ICON');
};
window.addEventListener('quickIconChange', handleQuickIconChange);
return () => {
window.removeEventListener('quickIconChange', handleQuickIconChange);
};
}, []);
// If items don't exist, return null (component will unmount)
if (!viewItem || !modelItem) {
return null;
@@ -127,12 +140,14 @@ export const NodeControls = ({ id }: Props) => {
/>
)}
{mode === 'CHANGE_ICON' && (
<Icons
key={viewItem.id}
iconCategories={iconCategories}
onClick={(_icon) => {
<QuickIconSelector
currentIconId={modelItem.icon}
onIconSelected={(_icon) => {
updateModelItem(viewItem.id, { icon: _icon.id });
}}
onClose={() => {
onSwitchMode('SETTINGS');
}}
/>
)}
</ControlsContainer>

View File

@@ -0,0 +1,221 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Box, Stack, Typography, Divider, TextField, InputAdornment } from '@mui/material';
import { Search as SearchIcon } from '@mui/icons-material';
import { Icon } from 'src/types';
import { useModelStore } from 'src/stores/modelStore';
import { IconGrid } from '../IconSelectionControls/IconGrid';
import { Section } from '../components/Section';
interface Props {
onIconSelected: (icon: Icon) => void;
onClose?: () => void;
currentIconId?: string;
}
// Store recently used icons in localStorage
const RECENT_ICONS_KEY = 'fossflow-recent-icons';
const MAX_RECENT_ICONS = 12;
const getRecentIcons = (): string[] => {
try {
const stored = localStorage.getItem(RECENT_ICONS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
};
const addToRecentIcons = (iconId: string) => {
const recent = getRecentIcons();
// Remove if already exists and add to front
const filtered = recent.filter(id => id !== iconId);
const updated = [iconId, ...filtered].slice(0, MAX_RECENT_ICONS);
localStorage.setItem(RECENT_ICONS_KEY, JSON.stringify(updated));
};
export const QuickIconSelector = ({ onIconSelected, onClose, currentIconId }: Props) => {
const [searchTerm, setSearchTerm] = useState('');
const [hoveredIndex, setHoveredIndex] = useState(0);
const searchInputRef = useRef<HTMLInputElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const icons = useModelStore((state) => state.icons);
const items = useModelStore((state) => state.items);
// Get recently used icons
const recentIconIds = useMemo(() => getRecentIcons(), []);
const recentIcons = useMemo(() => {
return recentIconIds
.map(id => icons.find(icon => icon.id === id))
.filter(Boolean) as Icon[];
}, [recentIconIds, icons]);
// Get most commonly used icons in current diagram
const commonIcons = useMemo(() => {
const iconUsage = new Map<string, number>();
// Count icon usage
items.forEach(item => {
if (item.icon) {
iconUsage.set(item.icon, (iconUsage.get(item.icon) || 0) + 1);
}
});
// Sort by usage and get top icons
const sorted = Array.from(iconUsage.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([iconId]) => icons.find(icon => icon.id === iconId))
.filter(Boolean) as Icon[];
return sorted;
}, [items, icons]);
// Filter icons based on search
const filteredIcons = useMemo(() => {
if (!searchTerm) {
// Show recent icons when no search
if (recentIcons.length > 0) {
return recentIcons;
}
// Show common icons if no recent
if (commonIcons.length > 0) {
return commonIcons;
}
// Show first 20 icons as fallback
return icons.slice(0, 20);
}
const regex = new RegExp(searchTerm, 'gi');
return icons.filter(icon => regex.test(icon.name));
}, [searchTerm, icons, recentIcons, commonIcons]);
// Focus search input on mount
useEffect(() => {
searchInputRef.current?.focus();
}, []);
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const itemsPerRow = 4; // Adjust based on your grid layout
const totalItems = filteredIcons.length;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHoveredIndex(prev =>
Math.min(prev + itemsPerRow, totalItems - 1)
);
break;
case 'ArrowUp':
e.preventDefault();
setHoveredIndex(prev =>
Math.max(prev - itemsPerRow, 0)
);
break;
case 'ArrowLeft':
e.preventDefault();
setHoveredIndex(prev =>
prev > 0 ? prev - 1 : prev
);
break;
case 'ArrowRight':
e.preventDefault();
setHoveredIndex(prev =>
prev < totalItems - 1 ? prev + 1 : prev
);
break;
case 'Enter':
e.preventDefault();
if (filteredIcons[hoveredIndex]) {
handleIconSelect(filteredIcons[hoveredIndex]);
}
break;
case 'Escape':
e.preventDefault();
onClose?.();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [filteredIcons, hoveredIndex, onClose]);
const handleIconSelect = useCallback((icon: Icon) => {
addToRecentIcons(icon.id);
onIconSelected(icon);
}, [onIconSelected]);
const handleIconDoubleClick = useCallback((icon: Icon) => {
handleIconSelect(icon);
onClose?.();
}, [handleIconSelect, onClose]);
return (
<Box>
<Section sx={{ py: 2 }}>
<Stack spacing={2}>
{/* Search Box */}
<TextField
ref={searchInputRef}
fullWidth
placeholder="Search icons (press Enter to select)"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setHoveredIndex(0); // Reset hover when searching
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
)
}}
size="small"
autoFocus
/>
{/* Section Headers */}
{!searchTerm && recentIcons.length > 0 && (
<Typography variant="caption" color="text.secondary">
RECENTLY USED
</Typography>
)}
{!searchTerm && recentIcons.length === 0 && commonIcons.length > 0 && (
<Typography variant="caption" color="text.secondary">
COMMONLY USED IN THIS DIAGRAM
</Typography>
)}
{searchTerm && (
<Typography variant="caption" color="text.secondary">
SEARCH RESULTS ({filteredIcons.length} icons)
</Typography>
)}
</Stack>
</Section>
<Divider />
{/* Icon Grid */}
<Box ref={gridRef} sx={{ maxHeight: 400, overflowY: 'auto' }}>
<IconGrid
icons={filteredIcons}
onClick={handleIconSelect}
onDoubleClick={handleIconDoubleClick}
hoveredIndex={hoveredIndex}
onHover={setHoveredIndex}
/>
</Box>
{/* Help Text */}
<Section sx={{ py: 1 }}>
<Typography variant="caption" color="text.secondary">
Use arrow keys to navigate Enter to select Double-click to select and close
</Typography>
</Section>
</Box>
);
};

View File

@@ -102,6 +102,14 @@ export const useInteractionManager = () => {
const hotkeyMapping = HOTKEY_PROFILES[uiState.hotkeyProfile];
const key = e.key.toLowerCase();
// Quick icon selection for selected node (when ItemControls is an ItemReference with type 'ITEM')
if (key === 'i' && uiState.itemControls && 'id' in uiState.itemControls && uiState.itemControls.type === 'ITEM') {
e.preventDefault();
// Trigger icon change mode
const event = new CustomEvent('quickIconChange');
window.dispatchEvent(event);
}
// Check if key matches any hotkey
if (hotkeyMapping.select && key === hotkeyMapping.select) {
e.preventDefault();