From 92c65f40dfee824eacf670baf4619b31b109097d Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 8 Mar 2023 22:45:25 +0000 Subject: [PATCH 1/5] Add support for custom emoji suggestions --- .../MarkdownEditor/MarkdownEditor.stories.tsx | 1 + .../suggestions/_useEmojiSuggestions.tsx | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx b/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx index f140894d2c2..de951a2eac9 100644 --- a/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx +++ b/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx @@ -163,6 +163,7 @@ const emojis: Emoji[] = [ {name: 'raised_hand', character: '✋'}, {name: 'thumbsup', character: '👍'}, {name: 'thumbsdown', character: '👎'}, + {name: 'octocat', url: 'https://github.githubassets.com/images/icons/emoji/octocat.png'}, ] const references: Reference[] = [ diff --git a/src/drafts/MarkdownEditor/suggestions/_useEmojiSuggestions.tsx b/src/drafts/MarkdownEditor/suggestions/_useEmojiSuggestions.tsx index c9e78942680..ec3108f1387 100644 --- a/src/drafts/MarkdownEditor/suggestions/_useEmojiSuggestions.tsx +++ b/src/drafts/MarkdownEditor/suggestions/_useEmojiSuggestions.tsx @@ -3,24 +3,40 @@ import {suggestionsCalculator, UseSuggestionsHook} from '.' import {ActionList} from '../../../ActionList' import {Suggestion, Trigger} from '../../InlineAutocomplete' -export type Emoji = { +type BaseEmoji = { /** Name (shortcode) of the emoji. Do not include the wrapping `:` symbols. */ name: string +} + +type UnicodeEmoji = BaseEmoji & { /** Unicode representation of the emoji. */ character: string } +type CustomEmoji = BaseEmoji & { + /** URL to an image of the emoji. */ + url: string +} + +export type Emoji = UnicodeEmoji | CustomEmoji + const trigger: Trigger = { triggerChar: ':', keepTriggerCharOnCommit: false, } const emojiToSugggestion = (emoji: Emoji): Suggestion => ({ - value: emoji.character, - key: emoji.name, // emoji characters may not be unique - ie haircut and haircut_man both have the same emoji codepoint. But names are guarunteed to be unique. + value: 'character' in emoji ? emoji.character : `:${emoji.name}:`, + key: emoji.name, // emoji characters may not be unique - ie haircut and haircut_man both have the same emoji codepoint. But names are guaranteed to be unique. render: props => ( - {emoji.character} + + {'character' in emoji ? ( + emoji.character + ) : ( + {`${emoji.name} + )} + {emoji.name} ), From 684cd19f1e218d6e799506cca43e39ecf25dd14e Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 9 Mar 2023 16:27:10 +0000 Subject: [PATCH 2/5] Allow declaratively setting suggestions as "loading" --- src/drafts/MarkdownEditor/suggestions/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/drafts/MarkdownEditor/suggestions/index.ts b/src/drafts/MarkdownEditor/suggestions/index.ts index 664180f3438..a00939c8ffd 100644 --- a/src/drafts/MarkdownEditor/suggestions/index.ts +++ b/src/drafts/MarkdownEditor/suggestions/index.ts @@ -2,11 +2,11 @@ import {Suggestion, Trigger} from '../../InlineAutocomplete' const MAX_SUGGESTIONS = 5 -export type SuggestionOptions = T[] | (() => Promise) +export type SuggestionOptions = T[] | (() => Promise) | 'loading' export type UseSuggestionsHook = (options: SuggestionOptions) => { trigger: Trigger - calculateSuggestions: (query: string) => Promise + calculateSuggestions: (query: string) => Promise } export const suggestionsCalculator = @@ -16,6 +16,8 @@ export const suggestionsCalculator = toSuggestion: (option: T) => Suggestion, ) => async (query: string) => { + if (options === 'loading') return 'loading' + const optionsArray = Array.isArray(options) ? options : await options() // If the query is empty, scores will be -INFINITY From 236334f768f02e76ab1dc8b44289a984cbe68c7a Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 9 Mar 2023 21:46:00 +0000 Subject: [PATCH 3/5] Recalculate suggestions on update --- src/drafts/MarkdownEditor/_MarkdownInput.tsx | 51 +++++++++++++++---- .../suggestions/_useEmojiSuggestions.tsx | 16 ++++-- .../suggestions/_useMentionSuggestions.tsx | 16 ++++-- .../suggestions/_useReferenceSuggestions.tsx | 22 +++++--- 4 files changed, 76 insertions(+), 29 deletions(-) diff --git a/src/drafts/MarkdownEditor/_MarkdownInput.tsx b/src/drafts/MarkdownEditor/_MarkdownInput.tsx index 71897ea389f..661bb66ce18 100644 --- a/src/drafts/MarkdownEditor/_MarkdownInput.tsx +++ b/src/drafts/MarkdownEditor/_MarkdownInput.tsx @@ -55,6 +55,7 @@ export const MarkdownInput = forwardRef forwardedRef, ) => { const [suggestions, setSuggestions] = useState(null) + const [event, setEvent] = useState(null) const {trigger: emojiTrigger, calculateSuggestions: calculateEmojiSuggestions} = useEmojiSuggestions( emojiSuggestions ?? [], @@ -71,17 +72,45 @@ export const MarkdownInput = forwardRef [mentionsTrigger, referencesTrigger, emojiTrigger], ) - const onShowSuggestions = async (event: ShowSuggestionsEvent) => { - setSuggestions('loading') - if (event.trigger.triggerChar === emojiTrigger.triggerChar) { - setSuggestions(await calculateEmojiSuggestions(event.query)) - } else if (event.trigger.triggerChar === mentionsTrigger.triggerChar) { - setSuggestions(await calculateMentionSuggestions(event.query)) - } else if (event.trigger.triggerChar === referencesTrigger.triggerChar) { - setSuggestions(await calculateReferenceSuggestions(event.query)) - } + const lastEventRef = useRef(null) + + const onHideSuggestions = () => { + setEvent(null) + setSuggestions(null) // the effect would do this anyway, but this allows React to batch the update } + // running the calculation in an effect (rather than in the onShowSuggestions handler) allows us + // to automatically recalculate if the suggestions change while the menu is open + useEffect(() => { + if (!event) { + setSuggestions(null) + return + } + + // (prettier vs. eslint conflict) + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;(async function () { + lastEventRef.current = event + setSuggestions('loading') + if (event.trigger.triggerChar === emojiTrigger.triggerChar) { + setSuggestions(await calculateEmojiSuggestions(event.query)) + } else if (event.trigger.triggerChar === mentionsTrigger.triggerChar) { + setSuggestions(await calculateMentionSuggestions(event.query)) + } else if (event.trigger.triggerChar === referencesTrigger.triggerChar) { + setSuggestions(await calculateReferenceSuggestions(event.query)) + } + })() + }, [ + event, + calculateEmojiSuggestions, + calculateMentionSuggestions, + calculateReferenceSuggestions, + // The triggers never actually change because they are statically defined + emojiTrigger, + mentionsTrigger, + referencesTrigger, + ]) + const ref = useRef(null) useRefObjectAsForwardedRef(forwardedRef, ref) @@ -99,8 +128,8 @@ export const MarkdownInput = forwardRef onShowSuggestions(e)} - onHideSuggestions={() => setSuggestions(null)} + onShowSuggestions={setEvent} + onHideSuggestions={onHideSuggestions} sx={{flex: 'auto'}} tabInsertsSuggestions > diff --git a/src/drafts/MarkdownEditor/suggestions/_useEmojiSuggestions.tsx b/src/drafts/MarkdownEditor/suggestions/_useEmojiSuggestions.tsx index ec3108f1387..7c40dd82230 100644 --- a/src/drafts/MarkdownEditor/suggestions/_useEmojiSuggestions.tsx +++ b/src/drafts/MarkdownEditor/suggestions/_useEmojiSuggestions.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useMemo} from 'react' import {suggestionsCalculator, UseSuggestionsHook} from '.' import {ActionList} from '../../../ActionList' import {Suggestion, Trigger} from '../../InlineAutocomplete' @@ -56,7 +56,13 @@ const scoreSuggestion = (query: string, emoji: Emoji): number => { return score } -export const useEmojiSuggestions: UseSuggestionsHook = emojis => ({ - calculateSuggestions: suggestionsCalculator(emojis, scoreSuggestion, emojiToSugggestion), - trigger, -}) +export const useEmojiSuggestions: UseSuggestionsHook = emojis => { + const calculateSuggestions = useMemo( + () => suggestionsCalculator(emojis, scoreSuggestion, emojiToSugggestion), + [emojis], + ) + return { + calculateSuggestions, + trigger, + } +} diff --git a/src/drafts/MarkdownEditor/suggestions/_useMentionSuggestions.tsx b/src/drafts/MarkdownEditor/suggestions/_useMentionSuggestions.tsx index b3ecb41ea4a..386b4e4165e 100644 --- a/src/drafts/MarkdownEditor/suggestions/_useMentionSuggestions.tsx +++ b/src/drafts/MarkdownEditor/suggestions/_useMentionSuggestions.tsx @@ -1,5 +1,5 @@ import {score} from 'fzy.js' -import React from 'react' +import React, {useMemo} from 'react' import {suggestionsCalculator, UseSuggestionsHook} from '.' import {ActionList} from '../../../ActionList' import {Suggestion, Trigger} from '../../InlineAutocomplete' @@ -37,7 +37,13 @@ const scoreSuggestion = (query: string, mentionable: Mentionable): number => { return fzyScore } -export const useMentionSuggestions: UseSuggestionsHook = mentionables => ({ - calculateSuggestions: suggestionsCalculator(mentionables, scoreSuggestion, mentionableToSuggestion), - trigger, -}) +export const useMentionSuggestions: UseSuggestionsHook = mentionables => { + const calculateSuggestions = useMemo( + () => suggestionsCalculator(mentionables, scoreSuggestion, mentionableToSuggestion), + [mentionables], + ) + return { + calculateSuggestions, + trigger, + } +} diff --git a/src/drafts/MarkdownEditor/suggestions/_useReferenceSuggestions.tsx b/src/drafts/MarkdownEditor/suggestions/_useReferenceSuggestions.tsx index 46f89269f52..efd97c4f582 100644 --- a/src/drafts/MarkdownEditor/suggestions/_useReferenceSuggestions.tsx +++ b/src/drafts/MarkdownEditor/suggestions/_useReferenceSuggestions.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useMemo} from 'react' import {suggestionsCalculator, UseSuggestionsHook} from '.' import {ActionList} from '../../../ActionList' import {Suggestion, Trigger} from '../../InlineAutocomplete' @@ -51,10 +51,16 @@ const scoreSuggestion = (query: string, reference: Reference): number => { return fzyScore === Infinity ? -Infinity : fzyScore } -export const useReferenceSuggestions: UseSuggestionsHook = references => ({ - calculateSuggestions: async (query: string) => { - if (/^\d+\s/.test(query)) return [] // don't return anything if the query is in the form #123 ..., assuming they already have the number they want - return suggestionsCalculator(references, scoreSuggestion, referenceToSuggestion)(query) - }, - trigger, -}) +export const useReferenceSuggestions: UseSuggestionsHook = references => { + const calculateSuggestions = useMemo(() => { + const calculator = suggestionsCalculator(references, scoreSuggestion, referenceToSuggestion) + return async (query: string) => { + if (/^\d+\s/.test(query)) return [] // don't return anything if the query is in the form #123 ..., assuming they already have the number they want + return calculator(query) + } + }, [references]) + return { + calculateSuggestions, + trigger, + } +} From f2b35d76a6021cc9f2929b2180514f3dae4813d0 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 9 Mar 2023 16:51:22 -0500 Subject: [PATCH 4/5] Create .changeset/silly-plants-draw.md --- .changeset/silly-plants-draw.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/silly-plants-draw.md diff --git a/.changeset/silly-plants-draw.md b/.changeset/silly-plants-draw.md new file mode 100644 index 00000000000..5231b92bad0 --- /dev/null +++ b/.changeset/silly-plants-draw.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Recalculate autocomplete suggestions if the input data changes while the menu is open From 4acd0c85d5113cc190119a39d03ac4c22a03701f Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 14 Mar 2023 17:38:33 +0000 Subject: [PATCH 5/5] Fix bug where page would be unresponsive if no suggestions provided --- src/drafts/MarkdownEditor/_MarkdownInput.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/drafts/MarkdownEditor/_MarkdownInput.tsx b/src/drafts/MarkdownEditor/_MarkdownInput.tsx index 661bb66ce18..6b78f2e5892 100644 --- a/src/drafts/MarkdownEditor/_MarkdownInput.tsx +++ b/src/drafts/MarkdownEditor/_MarkdownInput.tsx @@ -30,6 +30,8 @@ interface MarkdownInputProps extends Omit { visible: boolean } +const emptyArray: [] = [] // constant reference to avoid re-running effects + export const MarkdownInput = forwardRef( ( { @@ -58,13 +60,13 @@ export const MarkdownInput = forwardRef const [event, setEvent] = useState(null) const {trigger: emojiTrigger, calculateSuggestions: calculateEmojiSuggestions} = useEmojiSuggestions( - emojiSuggestions ?? [], + emojiSuggestions ?? emptyArray, ) const {trigger: mentionsTrigger, calculateSuggestions: calculateMentionSuggestions} = useMentionSuggestions( - mentionSuggestions ?? [], + mentionSuggestions ?? emptyArray, ) const {trigger: referencesTrigger, calculateSuggestions: calculateReferenceSuggestions} = useReferenceSuggestions( - referenceSuggestions ?? [], + referenceSuggestions ?? emptyArray, ) const triggers = useMemo(