Skip to content

Commit f05746e

Browse files
committed
Populate list of suggestions matching @mentions
1 parent 039204b commit f05746e

File tree

7 files changed

+419
-44
lines changed

7 files changed

+419
-44
lines changed

src/sidebar/components/Annotation/AnnotationEditor.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ function AnnotationEditor({
167167

168168
const textStyle = applyTheme(['annotationFontFamily'], settings);
169169

170+
const atMentionsEnabled = store.isFeatureEnabled('at_mentions');
171+
const usersWhoAnnotated = store.usersWhoAnnotated();
172+
170173
return (
171174
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
172175
<div
@@ -179,7 +182,8 @@ function AnnotationEditor({
179182
label={isReplyAnno ? 'Enter reply' : 'Enter comment'}
180183
text={text}
181184
onEditText={onEditText}
182-
atMentionsEnabled={store.isFeatureEnabled('at_mentions')}
185+
atMentionsEnabled={atMentionsEnabled}
186+
usersWhoAnnotated={usersWhoAnnotated}
183187
/>
184188
<TagEditor
185189
onAddTag={onAddTag}

src/sidebar/components/Annotation/test/AnnotationEditor-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe('AnnotationEditor', () => {
6262
removeDraft: sinon.stub(),
6363
removeAnnotations: sinon.stub(),
6464
isFeatureEnabled: sinon.stub().returns(false),
65+
usersWhoAnnotated: sinon.stub().returns([]),
6566
};
6667

6768
$imports.$mock(mockImportedComponents());

src/sidebar/components/MarkdownEditor.tsx

Lines changed: 162 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ import {
2020
import type { IconComponent } from '@hypothesis/frontend-shared/lib/types';
2121
import classnames from 'classnames';
2222
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';
2430

25-
import { ListenerCollection } from '../../shared/listener-collection';
2631
import { isMacOS } from '../../shared/user-agent';
2732
import {
2833
LinkType,
@@ -31,7 +36,10 @@ import {
3136
toggleSpanStyle,
3237
} from '../markdown-commands';
3338
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';
3543
import MarkdownView from './MarkdownView';
3644

3745
/**
@@ -181,49 +189,91 @@ function ToolbarButton({
181189
);
182190
}
183191

192+
export type UserItem = {
193+
username: string;
194+
displayName: string | null;
195+
};
196+
184197
type TextAreaProps = {
185198
classes?: string;
186199
containerRef?: Ref<HTMLTextAreaElement>;
187200
atMentionsEnabled: boolean;
201+
usersWhoAnnotated: UserItem[];
202+
onEditText: (text: string) => void;
188203
};
189204

190205
function TextArea({
191206
classes,
192207
containerRef,
193208
atMentionsEnabled,
209+
usersWhoAnnotated,
210+
onEditText,
211+
onKeyDown,
194212
...restProps
195-
}: TextAreaProps & JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
213+
}: TextAreaProps & JSX.TextareaHTMLAttributes) {
196214
const [popoverOpen, setPopoverOpen] = useState(false);
215+
const [activeMention, setActiveMention] = useState<string>();
197216
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 [];
202221
}
203222

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+
207242
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);
218251
}
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);
220265

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}`);
224267

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+
);
227277

228278
return (
229279
<div className="relative">
@@ -234,17 +284,90 @@ function TextArea({
234284
'focus:bg-white focus:outline-none focus:shadow-focus-inner',
235285
classes,
236286
)}
287+
onInput={(e: Event) => onEditText((e.target as HTMLInputElement).value)}
237288
{...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)}
238326
ref={textareaRef}
239327
/>
240328
{atMentionsEnabled && (
241329
<Popover
242330
open={popoverOpen}
243331
onClose={() => setPopoverOpen(false)}
244332
anchorElementRef={textareaRef}
245-
classes="p-2"
333+
classes="p-1"
246334
>
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>
248371
</Popover>
249372
)}
250373
</div>
@@ -392,6 +515,13 @@ export type MarkdownEditorProps = {
392515
text: string;
393516

394517
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[];
395525
};
396526

397527
/**
@@ -403,6 +533,7 @@ export default function MarkdownEditor({
403533
onEditText = () => {},
404534
text,
405535
textStyle = {},
536+
usersWhoAnnotated,
406537
}: MarkdownEditorProps) {
407538
// Whether the preview mode is currently active.
408539
const [preview, setPreview] = useState(false);
@@ -467,12 +598,11 @@ export default function MarkdownEditor({
467598
containerRef={input}
468599
onClick={(e: Event) => e.stopPropagation()}
469600
onKeyDown={handleKeyDown}
470-
onInput={(e: Event) =>
471-
onEditText((e.target as HTMLInputElement).value)
472-
}
601+
onEditText={onEditText}
473602
value={text}
474603
style={textStyle}
475604
atMentionsEnabled={atMentionsEnabled}
605+
usersWhoAnnotated={usersWhoAnnotated}
476606
/>
477607
)}
478608
</div>

0 commit comments

Comments
 (0)