diff --git a/app/src/components/TermInput.js b/app/src/components/TermInput.js index 22125e1..a181f76 100644 --- a/app/src/components/TermInput.js +++ b/app/src/components/TermInput.js @@ -1,5 +1,6 @@ import React, {useState, useRef} from 'react'; -import * as diacritics from 'diacritics'; +import * as fuzzy from '../data/fuzzy.js'; +import * as util from '../util.js'; /** * Codes des touches du clavier par nom. @@ -13,41 +14,6 @@ const keys = Object.assign(Object.create(null), { 'down': 40, }); -/** - * Met le nom d’un terme sous forme normalisée pour la recherche approximative. - * - * Dans la forme normalisée, tous les accents sont ôtés et la casse est réduite - * en minuscules. - * - * @param name Nom à normaliser. - * @return Liste des mots du nom de terme normalisé. - */ -const normalizeName = name => - diacritics.remove(name.toLowerCase()) - .split(/\s+/g); - -/** - * Vérifie si un terme est similaire à un préfixe saisi. - * - * @param prefix Préfixe saisi. - * @param term Terme à vérifier. - * @return Vrai si le terme est considéré comme similaire au préfixe. - */ -const termMatchesPrefix = (prefix, {name, alias}) => -{ - const haystack = [name].concat(alias) - .map(normalizeName) - .reduce((prev, next) => prev.concat(next), []); - - const needle = normalizeName(prefix); - - return needle.every(item => - haystack.some(word => - word.startsWith(item) - ) - ); -}; - /** * Zone de saisie des termes de recherche. * @@ -62,10 +28,12 @@ const TermInput = ({terms, availableTerms, setTerms}) => const [value, setValue] = useState(''); // Liste des termes suggérés par autocomplétion - const [suggestions, setSuggestions] = useState([]); - - // Terme ayant le focus parmi les suggestions - const [focusedSuggestion, setFocusedSuggestion] = useState(0); + const { + list: suggestions, + setList: setSuggestions, + focus: focusedSuggestion, + setFocus: setFocusedSuggestion + } = util.useFocusableList(); // Référence au champ de saisie const inputRef = useRef(null); @@ -86,7 +54,6 @@ const TermInput = ({terms, availableTerms, setTerms}) => setTerms(terms.concat([term])); setValue(''); setSuggestions([]); - setFocusedSuggestion(0); if (inputRef.current !== null) { @@ -135,16 +102,13 @@ const TermInput = ({terms, availableTerms, setTerms}) => ev.preventDefault(); setTerms(terms.slice(0, -1)); } - else if (ev.keyCode === keys.up && focusedSuggestion > 0) + else if (ev.keyCode === keys.up) { // Touche Haut : focus de la suggestion précédente ev.preventDefault(); setFocusedSuggestion(focusedSuggestion - 1); } - else if ( - ev.keyCode === keys.down - && focusedSuggestion < suggestions.length - 1 - ) + else if (ev.keyCode === keys.down) { // Touche Bas : focus de la suggestion suivante ev.preventDefault(); @@ -169,16 +133,17 @@ const TermInput = ({terms, availableTerms, setTerms}) => } else { - setSuggestions(availableTerms + setSuggestions(fuzzy.filterTerms( + // Filtre les termes correspondant à la saisie + nextValue, + // Sélectionne les termes qui n’ont pas déjà été choisis - .filter(({id: suggestionId}) => + availableTerms.filter(({id: suggestionId}) => !terms.some(({id: termId}) => termId === suggestionId ) ) - // Filtre ceux dont le nom correspond à la saisie - .filter(termMatchesPrefix.bind(null, nextValue)) - ); + )); } }; diff --git a/app/src/data/fuzzy.js b/app/src/data/fuzzy.js new file mode 100644 index 0000000..dd31a9a --- /dev/null +++ b/app/src/data/fuzzy.js @@ -0,0 +1,77 @@ +import * as diacritics from 'diacritics'; + +/** + * Motifs de désuffixation des mots français. + */ +const stemPatterns = [ + [/aux$/, 'al'], + [/(.)s$/, '$1'], + [/(.)e$/, '$1'], +]; + +/** + * Motif de séparation des mots. + */ +const splitPattern = /[\s-]+/g; + +/** + * Désuffixe un mot français. + * + * @param word Mot originel. + * @return Mot désuffixé. + */ +const stemWord = word => +{ + let result = word; + + for (let [pattern, replacement] of stemPatterns) + { + result = result.replace(pattern, replacement); + } + + return result; +}; + +/** + * Met le nom d’un terme sous forme normalisée pour la recherche approximative. + * + * Dans la forme normalisée, tous les accents sont ôtés, la casse est réduite + * en minuscules et les mots sont désuffixés. + * + * @param name Nom à normaliser. + * @return Liste des mots du nom de terme normalisé. + */ +const normalizeName = name => + diacritics.remove(name.toLowerCase()) + .split(splitPattern) + .map(stemWord); + +/** + * Filtre une liste de termes pour ne garder que ceux qui sont similaires à une + * saisie. + * + * @param search Contenu de la saisie. + * @param term Terme à vérifier. + * @return Ensemble des termes sélectionnés. + */ +export const filterTerms = (search, terms) => +{ + // Normalisation des mots recherchés + const needle = normalizeName(search); + + console.log(needle); + + return terms.filter(term => + { + // Normalisation du nom et des alias du terme + const haystack = [term.name].concat(term.alias) + .map(normalizeName) + .reduce((prev, next) => prev.concat(next), []); + + return needle.every(item => + haystack.some(word => + word.startsWith(item) + ) + ); + }); +}; diff --git a/app/src/util.js b/app/src/util.js index 807b5e6..7999f32 100644 --- a/app/src/util.js +++ b/app/src/util.js @@ -24,3 +24,46 @@ export const useAsync = (initial, func, ...args) => return results; }; + +/** + * Crée un état composé d’une liste et d’un élément ayant le focus dans cette + * liste. À la modification de la liste ou de l’indice de l’élément ayant le + * focus, la contrainte focus ∈ [0, taille de la liste] est imposée. + * + * @return Objet contenant la liste, l’indice de l’élément ayant le focus ainsi + * que des fonctions de modification. + */ +export const useFocusableList = () => +{ + const [list, setList] = useState([]); + const [focus, setFocus] = useState(0); + + return { + list, + focus, + + /** + * Modifie la liste et s’assure que l’élément ayant le focus est + * toujours dans sa plage de valeurs possibles. + * + * @param nextList Nouvelle liste. + */ + setList(nextList) + { + setList(nextList); + setFocus(Math.min(nextList.length, focus)); + }, + + + /** + * Modifie l’élément ayant le focus en imposant les contraintes + * d’intégrité. + * + * @param nextFocus Indice du nouvel élément ayant le focus. + */ + setFocus(nextFocus) + { + setFocus(Math.min(nextList.length, Math.max(0, focus))); + } + }; +};