@@ -20,9 +20,14 @@ import {
20
20
import type { IconComponent } from '@hypothesis/frontend-shared/lib/types' ;
21
21
import classnames from 'classnames' ;
22
22
import type { Ref , JSX } from 'preact' ;
23
- import { useEffect , useMemo , useRef , useState } from 'preact/hooks' ;
23
+ import {
24
+ useCallback ,
25
+ useEffect ,
26
+ useMemo ,
27
+ useRef ,
28
+ useState ,
29
+ } from 'preact/hooks' ;
24
30
25
- import { ListenerCollection } from '../../shared/listener-collection' ;
26
31
import { isMacOS } from '../../shared/user-agent' ;
27
32
import {
28
33
LinkType ,
@@ -31,7 +36,10 @@ import {
31
36
toggleSpanStyle ,
32
37
} from '../markdown-commands' ;
33
38
import type { EditorState } from '../markdown-commands' ;
34
- import { termBeforePosition } from '../util/term-before-position' ;
39
+ import {
40
+ getTermCoordinatesAtPosition ,
41
+ termBeforePosition ,
42
+ } from '../util/term-before-position' ;
35
43
import MarkdownView from './MarkdownView' ;
36
44
37
45
/**
@@ -181,49 +189,91 @@ function ToolbarButton({
181
189
) ;
182
190
}
183
191
192
+ export type UserItem = {
193
+ username : string ;
194
+ displayName : string | null ;
195
+ } ;
196
+
184
197
type TextAreaProps = {
185
198
classes ?: string ;
186
199
containerRef ?: Ref < HTMLTextAreaElement > ;
187
200
atMentionsEnabled : boolean ;
201
+ usersWhoAnnotated : UserItem [ ] ;
202
+ onEditText : ( text : string ) => void ;
188
203
} ;
189
204
190
205
function TextArea ( {
191
206
classes,
192
207
containerRef,
193
208
atMentionsEnabled,
209
+ usersWhoAnnotated,
210
+ onEditText,
211
+ onKeyDown,
194
212
...restProps
195
- } : TextAreaProps & JSX . TextareaHTMLAttributes < HTMLTextAreaElement > ) {
213
+ } : TextAreaProps & JSX . TextareaHTMLAttributes ) {
196
214
const [ popoverOpen , setPopoverOpen ] = useState ( false ) ;
215
+ const [ activeMention , setActiveMention ] = useState < string > ( ) ;
197
216
const textareaRef = useSyncedRef ( containerRef ) ;
198
-
199
- useEffect ( ( ) => {
200
- if ( ! atMentionsEnabled ) {
201
- return ( ) => { } ;
217
+ const [ highlightedSuggestion , setHighlightedSuggestion ] = useState ( 0 ) ;
218
+ const suggestions = useMemo ( ( ) => {
219
+ if ( ! atMentionsEnabled || activeMention === undefined ) {
220
+ return [ ] ;
202
221
}
203
222
204
- const textarea = textareaRef . current ! ;
205
- const listenerCollection = new ListenerCollection ( ) ;
206
- const checkForMentionAtCaret = ( ) => {
223
+ return usersWhoAnnotated
224
+ . filter (
225
+ u =>
226
+ // Match all users if the active mention is empty, which happens right
227
+ // after typing `@`
228
+ ! activeMention ||
229
+ `${ u . username } ${ u . displayName ?? '' } `
230
+ . toLowerCase ( )
231
+ . match ( activeMention . toLowerCase ( ) ) ,
232
+ )
233
+ . slice ( 0 , 10 ) ;
234
+ } , [ activeMention , atMentionsEnabled , usersWhoAnnotated ] ) ;
235
+
236
+ const checkForMentionAtCaret = useCallback (
237
+ ( textarea : HTMLTextAreaElement ) => {
238
+ if ( ! atMentionsEnabled ) {
239
+ return ;
240
+ }
241
+
207
242
const term = termBeforePosition ( textarea . value , textarea . selectionStart ) ;
208
- setPopoverOpen ( term . startsWith ( '@' ) ) ;
209
- } ;
210
-
211
- // We listen for `keyup` to make sure the text in the textarea reflects the
212
- // just-pressed key when we evaluate it
213
- listenerCollection . add ( textarea , 'keyup' , e => {
214
- // `Esc` key is used to close the popover. Do nothing and let users close
215
- // it that way, even if the caret is in a mention
216
- if ( e . key !== 'Escape' ) {
217
- checkForMentionAtCaret ( ) ;
243
+ const isAtMention = term . startsWith ( '@' ) ;
244
+
245
+ setPopoverOpen ( isAtMention ) ;
246
+ setActiveMention ( isAtMention ? term . substring ( 1 ) : undefined ) ;
247
+
248
+ // Reset highlighted suggestion when closing the popover
249
+ if ( ! isAtMention ) {
250
+ setHighlightedSuggestion ( 0 ) ;
218
251
}
219
- } ) ;
252
+ } ,
253
+ [ atMentionsEnabled ] ,
254
+ ) ;
255
+ const applySuggestion = useCallback (
256
+ ( suggestion : UserItem ) => {
257
+ const textarea = textareaRef . current ! ;
258
+ const { value } = textarea ;
259
+ const { start, end } = getTermCoordinatesAtPosition (
260
+ value ,
261
+ textarea . selectionStart ,
262
+ ) ;
263
+ const beforeMention = value . slice ( 0 , start ) ;
264
+ const afterMention = value . slice ( end ) ;
220
265
221
- // When clicking the textarea it's possible the caret is moved "into" a
222
- // mention, so we check if the popover should be opened
223
- listenerCollection . add ( textarea , 'click' , checkForMentionAtCaret ) ;
266
+ onEditText ( `${ beforeMention } @${ suggestion . username } ${ afterMention } ` ) ;
224
267
225
- return ( ) => listenerCollection . removeAll ( ) ;
226
- } , [ atMentionsEnabled , popoverOpen , textareaRef ] ) ;
268
+ // TODO Preserve caret position after applying suggestion
269
+
270
+ // Close popover and reset highlighted suggestion once the value is
271
+ // replaced
272
+ setPopoverOpen ( false ) ;
273
+ setHighlightedSuggestion ( 0 ) ;
274
+ } ,
275
+ [ onEditText , textareaRef ] ,
276
+ ) ;
227
277
228
278
return (
229
279
< div className = "relative" >
@@ -234,17 +284,90 @@ function TextArea({
234
284
'focus:bg-white focus:outline-none focus:shadow-focus-inner' ,
235
285
classes ,
236
286
) }
287
+ onInput = { ( e : Event ) => onEditText ( ( e . target as HTMLInputElement ) . value ) }
237
288
{ ...restProps }
289
+ // We listen for `keyup` to make sure the text in the textarea reflects
290
+ // the just-pressed key when we evaluate it
291
+ onKeyUp = { e => {
292
+ // `Esc` key is used to close the popover. Do nothing and let users
293
+ // close it that way, even if the caret is in a mention.
294
+ // `Enter` is handled on keydown. Do not handle it here.
295
+ if ( ! [ 'Escape' , 'Enter' ] . includes ( e . key ) ) {
296
+ checkForMentionAtCaret ( e . target as HTMLTextAreaElement ) ;
297
+ }
298
+ } }
299
+ onKeyDown = { e => {
300
+ // Invoke original handler if present
301
+ onKeyDown ?.( e ) ;
302
+
303
+ if ( ! popoverOpen || suggestions . length === 0 ) {
304
+ return ;
305
+ }
306
+
307
+ // When vertical arrows pressed while the popover is open with
308
+ // suggestions, highlight the right suggestion.
309
+ // When `Enter` is pressed, apply highlighted suggestion.
310
+ if ( e . key === 'ArrowDown' ) {
311
+ e . preventDefault ( ) ;
312
+ setHighlightedSuggestion ( prev =>
313
+ Math . min ( prev + 1 , suggestions . length - 1 ) ,
314
+ ) ;
315
+ } else if ( e . key === 'ArrowUp' ) {
316
+ e . preventDefault ( ) ;
317
+ setHighlightedSuggestion ( prev => Math . max ( prev - 1 , 0 ) ) ;
318
+ } else if ( e . key === 'Enter' ) {
319
+ e . preventDefault ( ) ;
320
+ applySuggestion ( suggestions [ highlightedSuggestion ] ) ;
321
+ }
322
+ } }
323
+ // When clicking the textarea, it's possible the caret is moved "into" a
324
+ // mention, so we check if the popover should be opened
325
+ onClick = { e => checkForMentionAtCaret ( e . target as HTMLTextAreaElement ) }
238
326
ref = { textareaRef }
239
327
/>
240
328
{ atMentionsEnabled && (
241
329
< Popover
242
330
open = { popoverOpen }
243
331
onClose = { ( ) => setPopoverOpen ( false ) }
244
332
anchorElementRef = { textareaRef }
245
- classes = "p-2 "
333
+ classes = "p-1 "
246
334
>
247
- Suggestions
335
+ < ul
336
+ className = "flex-col gap-y-0.5"
337
+ role = "listbox"
338
+ aria-orientation = "vertical"
339
+ >
340
+ { suggestions . map ( ( s , index ) => (
341
+ // These options are indirectly handled via keyboard event
342
+ // handlers in the textarea, hence, we don't want to add keyboard
343
+ // event handler here, but we want to handle click events
344
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events
345
+ < li
346
+ key = { s . username }
347
+ className = { classnames (
348
+ 'flex justify-between items-center' ,
349
+ 'rounded p-2 hover:bg-grey-2' ,
350
+ {
351
+ 'bg-grey-2' : highlightedSuggestion === index ,
352
+ } ,
353
+ ) }
354
+ onClick = { e => {
355
+ e . stopPropagation ( ) ;
356
+ applySuggestion ( s ) ;
357
+ } }
358
+ role = "option"
359
+ aria-selected = { highlightedSuggestion === index }
360
+ >
361
+ < span className = "truncate" > { s . username } </ span >
362
+ < span className = "text-color-text-light" > { s . displayName } </ span >
363
+ </ li >
364
+ ) ) }
365
+ { suggestions . length === 0 && (
366
+ < li className = "italic p-2" data-testid = "suggestions-fallback" >
367
+ No matches. You can still write the username
368
+ </ li >
369
+ ) }
370
+ </ ul >
248
371
</ Popover >
249
372
) }
250
373
</ div >
@@ -392,6 +515,13 @@ export type MarkdownEditorProps = {
392
515
text : string ;
393
516
394
517
onEditText ?: ( text : string ) => void ;
518
+
519
+ /**
520
+ * List of users who have annotated current document and belong to active group.
521
+ * This is used to populate the @mentions suggestions, when `atMentionsEnabled`
522
+ * is `true`.
523
+ */
524
+ usersWhoAnnotated : UserItem [ ] ;
395
525
} ;
396
526
397
527
/**
@@ -403,6 +533,7 @@ export default function MarkdownEditor({
403
533
onEditText = ( ) => { } ,
404
534
text,
405
535
textStyle = { } ,
536
+ usersWhoAnnotated,
406
537
} : MarkdownEditorProps ) {
407
538
// Whether the preview mode is currently active.
408
539
const [ preview , setPreview ] = useState ( false ) ;
@@ -467,12 +598,11 @@ export default function MarkdownEditor({
467
598
containerRef = { input }
468
599
onClick = { ( e : Event ) => e . stopPropagation ( ) }
469
600
onKeyDown = { handleKeyDown }
470
- onInput = { ( e : Event ) =>
471
- onEditText ( ( e . target as HTMLInputElement ) . value )
472
- }
601
+ onEditText = { onEditText }
473
602
value = { text }
474
603
style = { textStyle }
475
604
atMentionsEnabled = { atMentionsEnabled }
605
+ usersWhoAnnotated = { usersWhoAnnotated }
476
606
/>
477
607
) }
478
608
</ div >
0 commit comments