diff --git a/src/sidebar/components/MarkdownEditor.tsx b/src/sidebar/components/MarkdownEditor.tsx index 9938b9bb1de..d6eabe03643 100644 --- a/src/sidebar/components/MarkdownEditor.tsx +++ b/src/sidebar/components/MarkdownEditor.tsx @@ -1,4 +1,10 @@ -import { Button, IconButton, Link } from '@hypothesis/frontend-shared'; +import { + Button, + IconButton, + Link, + Popover, + useSyncedRef, +} from '@hypothesis/frontend-shared'; import { EditorLatexIcon, EditorQuoteIcon, @@ -16,6 +22,7 @@ import classnames from 'classnames'; import type { Ref, JSX } from 'preact'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { ListenerCollection } from '../../shared/listener-collection'; import { isMacOS } from '../../shared/user-agent'; import { LinkType, @@ -24,6 +31,7 @@ import { toggleSpanStyle, } from '../markdown-commands'; import type { EditorState } from '../markdown-commands'; +import { useSidebarStore } from '../store'; import MarkdownView from './MarkdownView'; /** @@ -173,6 +181,20 @@ function ToolbarButton({ ); } +/** + * Get the word right before the caret in a textarea. + * A word is anything delimited by spaces or newlines. + */ +function getTermBeforeCaret(textarea: HTMLTextAreaElement): string { + const caretPosition = textarea.selectionStart; + const textBeforeCaret = textarea.value + .slice(0, caretPosition) + .replace(/\n/g, ' '); + const lastSpaceIndex = textBeforeCaret.lastIndexOf(' '); + + return textBeforeCaret.slice(lastSpaceIndex + 1); +} + type TextAreaProps = { classes?: string; containerRef?: Ref; @@ -183,17 +205,58 @@ function TextArea({ containerRef, ...restProps }: TextAreaProps & JSX.TextareaHTMLAttributes) { + const [popoverOpen, setPopoverOpen] = useState(false); + const textareaRef = useSyncedRef(containerRef); + const store = useSidebarStore(); + const areMentionsEnabled = store.isFeatureEnabled('at_mentions'); + + useEffect(() => { + if (!areMentionsEnabled) { + return () => {}; + } + + const textarea = textareaRef.current!; + const listenerCollection = new ListenerCollection(); + + // We listen for `keyup` to make sure the text in the textarea reflects the + // just-pressed key when we evaluate it + listenerCollection.add(textarea, 'keyup', e => { + // `Esc` key is used to close the popover. Do nothing and let users close + // it that way, even if the caret is in a mention + if (e.key === 'Escape') { + return; + } + setPopoverOpen(getTermBeforeCaret(textarea).startsWith('@')); + }); + + return () => { + listenerCollection.removeAll(); + }; + }, [areMentionsEnabled, popoverOpen, textareaRef]); + return ( -