Skip to content

Commit

Permalink
[icons] Improve icon search performance
Browse files Browse the repository at this point in the history
  • Loading branch information
oliviertassinari committed Dec 31, 2024
1 parent 16ff52a commit eefd1d6
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 65 deletions.
200 changes: 144 additions & 56 deletions docs/data/material/components/material-icons/SearchIcons.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import copy from 'clipboard-copy';
import InputBase from '@mui/material/InputBase';
import Typography from '@mui/material/Typography';
import PropTypes from 'prop-types';
import Grid from '@mui/material/Grid';
import Grid2 from '@mui/material/Grid2';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
Expand All @@ -15,7 +15,7 @@ import InputAdornment from '@mui/material/InputAdornment';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import flexsearch from 'flexsearch';
import MiniSearch from 'minisearch';
import SearchIcon from '@mui/icons-material/Search';
import FormControlLabel from '@mui/material/FormControlLabel';
import RadioGroup from '@mui/material/RadioGroup';
Expand Down Expand Up @@ -50,8 +50,6 @@ import useQueryParameterState from 'docs/src/modules/utils/useQueryParameterStat
import { HighlightedCode } from '@mui/docs/HighlightedCode';
import synonyms from './synonyms';

const FlexSearchIndex = flexsearch.Index;

// const mui = {
// ExitToApp,
// ExitToAppOutlined,
Expand Down Expand Up @@ -409,40 +407,38 @@ const DialogDetails = React.memo(function DialogDetails(props) {
{t('searchIcons.learnMore')}
</ImportLink>
<DialogContent>
<Grid container>
<Grid item xs>
<Grid container sx={{ justifyContent: 'center' }}>
<CanvasComponent as={selectedIcon.Component} />
</Grid>
</Grid>
<Grid item xs>
<Grid
<Grid2 container>
<Grid2 size={{ xs: 6 }} container sx={{ justifyContent: 'center' }}>
<CanvasComponent as={selectedIcon.Component} />
</Grid2>
<Grid2 size={{ xs: 6 }}>
<Grid2
container
sx={{ alignItems: 'flex-end', justifyContent: 'center' }}
>
<Grid item>
<Tooltip title="fontSize small">
<Grid2>
<Tooltip title={`fontSize="small"`}>
<FontSizeComponent
as={selectedIcon.Component}
fontSize="small"
/>
</Tooltip>
</Grid>
<Grid item>
<Tooltip title="fontSize medium">
</Grid2>
<Grid2>
<Tooltip title={`fontSize="medium"`}>
<FontSizeComponent as={selectedIcon.Component} />
</Tooltip>
</Grid>
<Grid item>
<Tooltip title="fontSize large">
</Grid2>
<Grid2>
<Tooltip title={`fontSize="large"`}>
<FontSizeComponent
as={selectedIcon.Component}
fontSize="large"
/>
</Tooltip>
</Grid>
</Grid>
<Grid container sx={{ justifyContent: 'center' }}>
</Grid2>
</Grid2>
<Grid2 container sx={{ justifyContent: 'center' }}>
<ContextComponent
as={selectedIcon.Component}
contextColor="primary"
Expand All @@ -451,8 +447,8 @@ const DialogDetails = React.memo(function DialogDetails(props) {
as={selectedIcon.Component}
contextColor="primaryInverse"
/>
</Grid>
<Grid container sx={{ justifyContent: 'center' }}>
</Grid2>
<Grid2 container sx={{ justifyContent: 'center' }}>
<ContextComponent
as={selectedIcon.Component}
contextColor="textPrimary"
Expand All @@ -461,8 +457,8 @@ const DialogDetails = React.memo(function DialogDetails(props) {
as={selectedIcon.Component}
contextColor="textPrimaryInverse"
/>
</Grid>
<Grid container sx={{ justifyContent: 'center' }}>
</Grid2>
<Grid2 container sx={{ justifyContent: 'center' }}>
<ContextComponent
as={selectedIcon.Component}
contextColor="textSecondary"
Expand All @@ -471,9 +467,9 @@ const DialogDetails = React.memo(function DialogDetails(props) {
as={selectedIcon.Component}
contextColor="textSecondaryInverse"
/>
</Grid>
</Grid>
</Grid>
</Grid2>
</Grid2>
</Grid2>
</DialogContent>
<DialogActions sx={{ borderTop: '1px solid', borderColor: 'divider' }}>
<Button onClick={handleClose}>{t('close')}</Button>
Expand All @@ -492,10 +488,11 @@ DialogDetails.propTypes = {
selectedIcon: PropTypes.object,
};

const Form = styled('form')({
const Form = styled('form')(({ theme }) => ({
position: 'sticky',
top: 80,
});
marginBottom: theme.spacing(1),
}));

const Paper = styled(MuiPaper)(({ theme }) => ({
position: 'sticky',
Expand All @@ -518,40 +515,111 @@ const Input = styled(InputBase)({
flex: 1,
});

const searchIndex = new FlexSearchIndex({
tokenize: 'full',
});

const allIconsMap = {};
const themeRegEx = /(Outlined|Rounded|TwoTone|Sharp)$/g;

const allIcons = Object.keys(mui)
.sort()
.sort() // Show ASC
.map((importName) => {
let theme = 'Filled';
let name = importName;

for (const currentTheme of ['Outlined', 'Rounded', 'TwoTone', 'Sharp']) {
if (importName.endsWith(currentTheme)) {
theme = currentTheme === 'TwoTone' ? 'Two tone' : currentTheme;
name = importName.slice(0, -currentTheme.length);
break;
}
const matchTheme = importName.match(themeRegEx);
if (matchTheme !== null) {
theme = matchTheme[0] === 'TwoTone' ? 'Two tone' : matchTheme[0];
name = importName.slice(0, -matchTheme[0].length);
}
let searchable = name;
if (synonyms[searchable]) {
searchable += ` ${synonyms[searchable]}`;
}
searchIndex.add(importName, searchable);

const icon = {
id: importName, // used by miniSearch
importName,
name,
theme,
searchable,
Component: mui[importName],
};
allIconsMap[importName] = icon;
return icon;
});

function addSuffixes(term, minLength) {
if (term == null) {
return undefined;
}

const tokens = [];

for (let i = 0; i <= term.length - minLength; i += 1) {
tokens.push(term.slice(i).toLowerCase());
}

return tokens;
}

const miniSearch = new MiniSearch({
fields: ['searchable'], // fields to index for full-text search
processTerm: (term) => addSuffixes(term, 4),
storeFields: ['name', 'Component'],
searchOptions: {
processTerm: MiniSearch.getDefault('processTerm'),
prefix: true,
fuzzy: 0.1, // Allow some typo
boostDocument: (documentId, term, storedFields) => {
// Show exact match first
return term.toLowerCase() === storedFields.name.toLowerCase() ? 2 : 1;
},
},
});

// Copied from mui-x/packages/x-data-grid-generator/src/services/asyncWorker.ts
// https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html#addAllAsync is crap.
function asyncWorker({ work, tasks, done }) {
const myNonEssentialWork = (deadline) => {
// If there is a surplus time in the frame, or timeout
while (
(deadline.timeRemaining() > 0 || deadline.didTimeout) &&
tasks.current > 0
) {
work();
}

if (tasks.current > 0) {
requestIdleCallback(myNonEssentialWork);
} else {
done();
}
};

// Don't use requestIdleCallback if the time is mock, better to run synchronously in such case.
if (typeof requestIdleCallback === 'function' && !requestIdleCallback.clock) {
requestIdleCallback(myNonEssentialWork);
} else {
while (tasks.current > 0) {
work();
}
done();
}
}

const indexation = new Promise((resolve) => {
const tasks = { current: allIcons.length };

function work() {
miniSearch.addAll([allIcons[tasks.current - 1]]);
tasks.current -= 1;
}

asyncWorker({
tasks,
work,
done: () => resolve(),
});
});

/**
* Returns the last defined value that has been passed in [value]
*/
Expand All @@ -570,6 +638,13 @@ export default function SearchIcons() {
const [selectedIcon, setSelectedIcon] = useQueryParameterState('selected', '');
const [query, setQuery] = useQueryParameterState('query', '');

const allThemeIcons = React.useMemo(
() => allIcons.filter((icon) => theme === icon.theme),
[theme],
);

const [icons, setIcons] = React.useState(allThemeIcons);

const handleOpenClick = React.useCallback(
(event) => {
setSelectedIcon(event.currentTarget.getAttribute('title'));
Expand All @@ -581,12 +656,24 @@ export default function SearchIcons() {
setSelectedIcon('');
}, [setSelectedIcon]);

const icons = React.useMemo(() => {
const keys = query === '' ? null : searchIndex.search(query, { limit: 3000 });
return (keys === null ? allIcons : keys.map((key) => allIconsMap[key])).filter(
(icon) => theme === icon.theme,
);
}, [query, theme]);
React.useEffect(() => {
if (query === '') {
setIcons(allThemeIcons);
return;
}

async function search() {
await indexation;
const keys = miniSearch.search(query);

setIcons(
keys
.map((key) => allIconsMap[key.id])
.filter((icon) => theme === icon.theme),
);
}
search();
}, [query, theme, allThemeIcons]);

const deferredIcons = React.useDeferredValue(icons);

Expand All @@ -607,15 +694,16 @@ export default function SearchIcons() {
);

return (
<Grid container sx={{ minHeight: 500 }}>
<Grid item xs={12} sm={3}>
<Grid2 container sx={{ minHeight: 500, width: '100%' }}>
<Grid2 size={{ xs: 12, sm: 3 }}>
<Form>
<Typography fontWeight={500} sx={{ mb: 1 }}>
Filter the style
</Typography>
<RadioGroup
value={theme}
onChange={(event) => setTheme(event.target.value)}
sx={{ ml: 0.5 }}
>
{['Filled', 'Outlined', 'Rounded', 'Two tone', 'Sharp'].map(
(currentTheme) => {
Expand All @@ -631,8 +719,8 @@ export default function SearchIcons() {
)}
</RadioGroup>
</Form>
</Grid>
<Grid item xs={12} sm={9}>
</Grid2>
<Grid2 size={{ xs: 12, sm: 9 }} sx={{ height: '100%' }}>
<Paper>
<IconButton sx={{ padding: '10px' }} aria-label="search">
<SearchIcon />
Expand All @@ -656,12 +744,12 @@ export default function SearchIcons() {
icons.length,
)} matching results`}</Typography>
<Icons icons={deferredIcons} handleOpenClick={handleOpenClick} />
</Grid>
</Grid2>
<DialogDetails
open={!!selectedIcon}
selectedIcon={dialogSelectedIcon}
handleClose={handleClose}
/>
</Grid>
</Grid2>
);
}
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@
"feed": "^4.2.2",
"fg-loadcss": "^3.1.0",
"final-form": "^4.20.10",
"flexsearch": "^0.7.43",
"fs-extra": "^11.2.0",
"json2mq": "^0.2.0",
"jss": "^10.10.0",
Expand All @@ -82,6 +81,7 @@
"lz-string": "^1.5.0",
"markdown-to-jsx": "^7.7.2",
"material-ui-popup-state": "^5.3.3",
"minisearch": "^7.1.0",
"next": "^15.1.2",
"notistack": "3.0.1",
"nprogress": "^0.2.0",
Expand Down
Loading

0 comments on commit eefd1d6

Please sign in to comment.