diff --git a/newIDE/app/src/AssetStore/BehaviorStore/BehaviorStoreContext.js b/newIDE/app/src/AssetStore/BehaviorStore/BehaviorStoreContext.js index cdc5a36707ff..9c4870eeeda8 100644 --- a/newIDE/app/src/AssetStore/BehaviorStore/BehaviorStoreContext.js +++ b/newIDE/app/src/AssetStore/BehaviorStore/BehaviorStoreContext.js @@ -9,7 +9,7 @@ import { import { type Filters } from '../../Utils/GDevelopServices/Filters'; import { useSearchStructuredItem, - type SearchMatch, + type SearchResult, } from '../../UI/Search/UseSearchStructuredItem'; import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext'; import { BEHAVIORS_FETCH_TIMEOUT } from '../../Utils/GlobalFetchTimeouts'; @@ -38,10 +38,9 @@ export type SearchableBehaviorMetadata = {| type BehaviorStoreState = {| filters: ?Filters, - searchResults: ?Array<{| - item: BehaviorShortHeader | SearchableBehaviorMetadata, - matches: SearchMatch[], - |}>, + searchResults: ?Array< + SearchResult + >, fetchBehaviors: () => void, error: ?Error, searchText: string, @@ -244,10 +243,9 @@ export const BehaviorStoreStateProvider = ({ [firstBehaviorIds, installedBehaviorMetadataList] ); - const searchResults: ?Array<{| - item: BehaviorShortHeader | SearchableBehaviorMetadata, - matches: SearchMatch[], - |}> = useSearchStructuredItem(allBehaviors, { + const searchResults: ?Array< + SearchResult + > = useSearchStructuredItem(allBehaviors, { searchText, chosenItemCategory: chosenCategory, chosenCategory: filtersState.chosenCategory, diff --git a/newIDE/app/src/AssetStore/ExampleStore/ExampleStoreContext.js b/newIDE/app/src/AssetStore/ExampleStore/ExampleStoreContext.js index 8c72ad669b01..82f14e73acac 100644 --- a/newIDE/app/src/AssetStore/ExampleStore/ExampleStoreContext.js +++ b/newIDE/app/src/AssetStore/ExampleStore/ExampleStoreContext.js @@ -8,7 +8,7 @@ import { import { type Filters } from '../../Utils/GDevelopServices/Filters'; import { useSearchStructuredItem, - type SearchMatch, + type SearchResult, } from '../../UI/Search/UseSearchStructuredItem'; import { EXAMPLES_FETCH_TIMEOUT } from '../../Utils/GlobalFetchTimeouts'; @@ -18,10 +18,7 @@ const firstExampleIds = []; type ExampleStoreState = {| exampleFilters: ?Filters, - exampleShortHeadersSearchResults: ?Array<{| - item: ExampleShortHeader, - matches: SearchMatch[], - |}>, + exampleShortHeadersSearchResults: ?Array>, fetchExamplesAndFilters: () => void, exampleShortHeaders: ?Array, error: ?Error, @@ -133,10 +130,9 @@ export const ExampleStoreStateProvider = ({ ); const { chosenCategory, chosenFilters } = filtersState; - const exampleShortHeadersSearchResults: ?Array<{| - item: ExampleShortHeader, - matches: SearchMatch[], - |}> = useSearchStructuredItem(exampleShortHeadersById, { + const exampleShortHeadersSearchResults: ?Array< + SearchResult + > = useSearchStructuredItem(exampleShortHeadersById, { searchText, chosenCategory, chosenFilters, diff --git a/newIDE/app/src/AssetStore/ExtensionStore/ExtensionStoreContext.js b/newIDE/app/src/AssetStore/ExtensionStore/ExtensionStoreContext.js index 0b821d86ee1a..d1ff6dd74daf 100644 --- a/newIDE/app/src/AssetStore/ExtensionStore/ExtensionStoreContext.js +++ b/newIDE/app/src/AssetStore/ExtensionStore/ExtensionStoreContext.js @@ -9,7 +9,7 @@ import { import { type Filters } from '../../Utils/GDevelopServices/Filters'; import { useSearchStructuredItem, - type SearchMatch, + type SearchResult, } from '../../UI/Search/UseSearchStructuredItem'; import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext'; import { EXTENSIONS_FETCH_TIMEOUT } from '../../Utils/GlobalFetchTimeouts'; @@ -21,10 +21,7 @@ const excludedCommunityTiers = new Set(['community']); type ExtensionStoreState = {| filters: ?Filters, - searchResults: ?Array<{| - item: ExtensionShortHeader, - matches: SearchMatch[], - |}>, + searchResults: ?Array>, fetchExtensionsAndFilters: () => void, error: ?Error, searchText: string, @@ -177,10 +174,9 @@ export const ExtensionStoreStateProvider = ({ [extensionShortHeadersByName] ); - const searchResults: ?Array<{| - item: ExtensionShortHeader, - matches: SearchMatch[], - |}> = useSearchStructuredItem(extensionShortHeadersByName, { + const searchResults: ?Array< + SearchResult + > = useSearchStructuredItem(extensionShortHeadersByName, { searchText, chosenItemCategory: chosenCategory, chosenCategory: filtersState.chosenCategory, diff --git a/newIDE/app/src/AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext.js b/newIDE/app/src/AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext.js index 3221926e324d..e85d4c1d0c8e 100644 --- a/newIDE/app/src/AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext.js +++ b/newIDE/app/src/AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext.js @@ -4,7 +4,7 @@ import { type FiltersState, useFilters } from '../../UI/Search/FiltersChooser'; import { type Filters } from '../../Utils/GDevelopServices/Filters'; import { useSearchStructuredItem, - type SearchMatch, + type SearchResult, } from '../../UI/Search/UseSearchStructuredItem'; import { useSearchItem } from '../../UI/Search/UseSearchItem'; import { @@ -44,10 +44,9 @@ type PrivateGameTemplateStoreState = {| setInitialGameTemplateUserFriendlySlug: string => void, }, exampleStore: { - privateGameTemplateListingDatasSearchResults: ?Array<{| - item: PrivateGameTemplateListingData, - matches: SearchMatch[], - |}>, + privateGameTemplateListingDatasSearchResults: ?Array< + SearchResult + >, searchText: string, setSearchText: string => void, filtersState: FiltersState, @@ -284,10 +283,9 @@ export const PrivateGameTemplateStoreStateProvider = ({ const currentPage = shopNavigationState.getCurrentPage(); - const privateGameTemplateListingDatasSearchResultsForExampleStore: ?Array<{| - item: PrivateGameTemplateListingData, - matches: SearchMatch[], - |}> = useSearchStructuredItem(privateGameTemplateListingDatasById, { + const privateGameTemplateListingDatasSearchResultsForExampleStore: ?Array< + SearchResult + > = useSearchStructuredItem(privateGameTemplateListingDatasById, { searchText: exampleStoreSearchText, chosenCategory: filtersStateForExampleStore.chosenCategory, chosenFilters: filtersStateForExampleStore.chosenFilters, diff --git a/newIDE/app/src/EventsSheet/InstructionEditor/InstructionOrObjectSelector.js b/newIDE/app/src/EventsSheet/InstructionEditor/InstructionOrObjectSelector.js index 0a70a378cef7..95ef2d3e85d5 100644 --- a/newIDE/app/src/EventsSheet/InstructionEditor/InstructionOrObjectSelector.js +++ b/newIDE/app/src/EventsSheet/InstructionEditor/InstructionOrObjectSelector.js @@ -25,9 +25,11 @@ import EmptyMessage from '../../UI/EmptyMessage'; import { type EventsScope } from '../../InstructionOrExpression/EventsScope'; import { type SearchResult, - tuneMatches, + sortResultsUsingExactMatches, sharedFuseConfiguration, getFuseSearchQueryForMultipleKeys, + nullifySingleCharacterMatches, + augmentSearchResult, } from '../../UI/Search/UseSearchStructuredItem'; import { Column, Line } from '../../UI/Grid'; import { enumerateFoldersInContainer } from '../../ObjectsList/EnumerateObjectFolderOrObject'; @@ -283,6 +285,7 @@ const InstructionOrObjectSelector = React.forwardRef< const instructionSearchApiRef = React.useRef( new Fuse(allInstructionsInfoRef.current, { ...sharedFuseConfiguration, + includeScore: true, // Use Fuse.js score to sort results that don't contain exact matches. keys: [ { name: 'displayedName', weight: 5 }, { name: 'fullGroupName', weight: 1 }, @@ -457,6 +460,7 @@ const InstructionOrObjectSelector = React.forwardRef< const search = React.useCallback((searchText: string) => { if (!searchText) return; + const lowerCaseSearchText = searchText.toLowerCase(); const matchingInstructions = moveDeprecatedInstructionsDown( instructionSearchApiRef.current @@ -467,10 +471,16 @@ const InstructionOrObjectSelector = React.forwardRef< 'description', ]) ) - .map(result => ({ - item: result.item, - matches: tuneMatches(result, searchText), - })) + .map(nullifySingleCharacterMatches) + .filter(Boolean) + .map(result => augmentSearchResult(result, lowerCaseSearchText)) + .sort( + sortResultsUsingExactMatches([ + 'displayedName', + 'description', + 'fullGroupName', + ]) + ) ); setSearchResults({ instructions: matchingInstructions }); @@ -676,7 +686,7 @@ const InstructionOrObjectSelector = React.forwardRef< [displayedInstructionsList, isSearching] ); - const hasNoObjects = !isSearching && !allObjectsList.length; + const displayEmptyMessage = !isSearching && !allObjectsList.length; const searchHasNoResults = isSearching && !hasResults; return ( @@ -737,7 +747,7 @@ const InstructionOrObjectSelector = React.forwardRef< )} - {hasNoObjects ? ( + {displayEmptyMessage && currentTab === 'objects' ? ( {getEmptyMessage(scope)} ) : searchHasNoResults ? ( @@ -753,7 +763,7 @@ const InstructionOrObjectSelector = React.forwardRef< display: (currentTab === 'objects' || isSearching) && !searchHasNoResults && - !hasNoObjects + !displayEmptyMessage ? 'unset' : 'none', }} diff --git a/newIDE/app/src/UI/Dialog.js b/newIDE/app/src/UI/Dialog.js index cd9cda08e1b4..f9d9dc94ded3 100644 --- a/newIDE/app/src/UI/Dialog.js +++ b/newIDE/app/src/UI/Dialog.js @@ -135,24 +135,30 @@ const useStylesForDialogContent = ({ forceScroll, }: {| forceScroll: boolean, -|}) => - makeStyles({ - root: { - ...(forceScroll ? { overflowY: 'scroll' } : {}), // Force a scrollbar to prevent layout shifts. - '&::-webkit-scrollbar': { - width: 11, - }, - '&::-webkit-scrollbar-track': { - background: 'rgba(0, 0, 0, 0.04)', - borderRadius: 6, - }, - '&::-webkit-scrollbar-thumb': { - border: '3px solid rgba(0, 0, 0, 0)', - backgroundClip: 'padding-box', - borderRadius: 6, - }, - }, - })(); +|}) => { + const useStyles = React.useMemo( + () => + makeStyles({ + root: { + ...(forceScroll ? { overflowY: 'scroll' } : {}), // Force a scrollbar to prevent layout shifts. + '&::-webkit-scrollbar': { + width: 11, + }, + '&::-webkit-scrollbar-track': { + background: 'rgba(0, 0, 0, 0.04)', + borderRadius: 6, + }, + '&::-webkit-scrollbar-thumb': { + border: '3px solid rgba(0, 0, 0, 0)', + backgroundClip: 'padding-box', + borderRadius: 6, + }, + }, + }), + [forceScroll] + ); + return useStyles(); +}; // We support a subset of the props supported by Material-UI v0.x Dialog // They should be self descriptive - refer to Material UI docs otherwise. @@ -364,6 +370,18 @@ const Dialog = ({ : minHeight === 'sm' ? styles.minHeightForSmallHeightModal : undefined; + const paperStyle = React.useMemo( + () => ({ + backgroundColor: gdevelopTheme.dialog.backgroundColor, + minHeight: paperMinHeight, + ...getAvoidSoftKeyboardStyle(softKeyboardBottomOffset), + }), + [ + gdevelopTheme.dialog.backgroundColor, + paperMinHeight, + softKeyboardBottomOffset, + ] + ); return ( = {| item: T, matches: SearchMatch[], + score?: number, +|}; +export type AugmentedSearchResult = {| + ...SearchResult, + matches: AugmentedSearchMatch[], |}; type SearchOptions = {| @@ -83,6 +93,11 @@ export const getFuseSearchQueryForMultipleKeys = ( }; }; +/** + * Method that optimizes the match object returned by Fuse.js in the case + * the indices are used to display the matches. + * It gets rid of the indices that do not match the search text exactly. + */ const tuneMatchIndices = (match: SearchMatch, searchText: string) => { const lowerCaseSearchText = searchText.toLowerCase(); return match.indices @@ -111,6 +126,72 @@ const tuneMatchIndices = (match: SearchMatch, searchText: string) => { .filter(Boolean); }; +const getFirstExactMatchPosition = ( + match: SearchMatch, + lowerCaseSearchText: string +) => { + let closestExactMatchIndex = null; + let closestExactMatchAtStartOfWordIndex = null; + for (const index of match.indices) { + const lowerCaseMatchedText = match.value + .slice(index[0], index[1] + 1) + .toLowerCase(); + // Using startsWith here instead of `===` because of this behavior of Fuse.js: + // Searching `trig` will return the instruction `Trigger once` but the match first index + // will be on the `Trigg` part of `Trigger once`, and not only on the part `Trig`, + // because the `g` is repeated. + const doesMatch = lowerCaseMatchedText.startsWith(lowerCaseSearchText); + if (!doesMatch) continue; + if (closestExactMatchIndex === null) { + closestExactMatchIndex = index[0]; + } + if (closestExactMatchAtStartOfWordIndex === null) { + const characterBeforeMatch = + index[0] === 0 ? ' ' : match.value[index[0] - 1]; + if (characterBeforeMatch.match(/[\s\-_/]/)) { + closestExactMatchAtStartOfWordIndex = index[0]; + break; + } + } + } + return { + closestExactMatchIndex, + closestExactMatchAtStartOfWordIndex, + }; +}; + +export const nullifySingleCharacterMatches = (result: SearchResult) => { + const matchesWithAtLeastOneSignificantIndex = result.matches + .map(match => { + const newIndices = match.indices + .map(index => (index[1] - index[0] > 0 ? index : null)) + .filter(Boolean); + if (newIndices.length > 0) { + return { ...match, indices: newIndices }; + } + return null; + }) + .filter(Boolean); + if (matchesWithAtLeastOneSignificantIndex.length > 0) { + return { ...result, matches: matchesWithAtLeastOneSignificantIndex }; + } + return null; +}; + +export const augmentSearchResult = ( + result: SearchResult, + lowerCaseSearchText: string +): AugmentedSearchResult => { + return { + item: result.item, + score: result.score, + matches: result.matches.map(match => ({ + ...match, + ...getFirstExactMatchPosition(match, lowerCaseSearchText), + })), + }; +}; + export const tuneMatches = (result: SearchResult, searchText: string) => result.matches.map(match => ({ key: match.key, @@ -118,6 +199,98 @@ export const tuneMatches = (result: SearchResult, searchText: string) => indices: tuneMatchIndices(match, searchText), })); +export const sortResultsUsingExactMatches = (orderedKeys: string[]) => { + return ( + resultA: AugmentedSearchResult, + resultB: AugmentedSearchResult + ) => { + // First give priority to result that have an exact match at start of word and not the other. + const resultAExactMatchesAtStartOfWordCount = resultA.matches.filter( + match => match.closestExactMatchAtStartOfWordIndex !== null + ).length; + const resultBExactMatchesAtStartOfWordCount = resultB.matches.filter( + match => match.closestExactMatchAtStartOfWordIndex !== null + ).length; + if ( + resultAExactMatchesAtStartOfWordCount > 0 && + resultBExactMatchesAtStartOfWordCount === 0 + ) { + return -1; + } + if ( + resultAExactMatchesAtStartOfWordCount === 0 && + resultBExactMatchesAtStartOfWordCount > 0 + ) { + return 1; + } + // Then give priority to result that have an exact match and not the other. + const resultAExactMatchesCount = resultA.matches.filter( + match => match.closestExactMatchIndex !== null + ).length; + const resultBExactMatchesCount = resultB.matches.filter( + match => match.closestExactMatchIndex !== null + ).length; + if (resultAExactMatchesCount > 0 && resultBExactMatchesCount === 0) { + return -1; + } + if (resultAExactMatchesCount === 0 && resultBExactMatchesCount > 0) { + return 1; + } + // If results have the same number of exact matches, both at start of word + // and in the whole text, use ordered keys and find matches in them. + for (const key of orderedKeys) { + const matchA = resultA.matches.find(match => match.key === key); + const matchB = resultB.matches.find(match => match.key === key); + if (matchA && matchB) { + if ( + matchA.closestExactMatchAtStartOfWordIndex !== null && + matchB.closestExactMatchAtStartOfWordIndex !== null + ) { + return ( + matchA.closestExactMatchAtStartOfWordIndex - + matchB.closestExactMatchAtStartOfWordIndex + ); + } + if ( + matchA.closestExactMatchAtStartOfWordIndex !== null && + matchB.closestExactMatchAtStartOfWordIndex === null + ) { + return -1; + } + if ( + matchA.closestExactMatchAtStartOfWordIndex === null && + matchB.closestExactMatchAtStartOfWordIndex !== null + ) { + return 1; + } + if ( + matchA.closestExactMatchIndex !== null && + matchB.closestExactMatchIndex !== null + ) { + return matchA.closestExactMatchIndex - matchB.closestExactMatchIndex; + } + if ( + matchA.closestExactMatchIndex !== null && + matchB.closestExactMatchIndex === null + ) { + return -1; + } + if ( + matchA.closestExactMatchIndex === null && + matchB.closestExactMatchIndex !== null + ) { + return 1; + } + } + } + // At that point, neither result have an exact match anywhere. + if (resultA.score !== undefined && resultB.score !== undefined) { + return resultA.score - resultB.score; + } + return -(resultA.matches.length - resultB.matches.length); + }; +}; + /** * Filter a list of items according to the chosen category * and the chosen filters. @@ -208,12 +381,11 @@ export const useSearchStructuredItem = ( defaultFirstSearchItemIds, shuffleResults = true, }: SearchOptions -): ?Array<{| item: SearchItem, matches: SearchMatch[] |}> => { +): ?Array> => { const searchApiRef = React.useRef(null); - const [searchResults, setSearchResults] = React.useState>(null); + const [searchResults, setSearchResults] = React.useState + >>(null); // Keep in memory a list of all the items, shuffled for // easing random discovery of items when no search is done. const orderedSearchResults: Array<