Skip to content

Commit

Permalink
Display a popover when hovering over highlighted mentions
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Feb 12, 2025
1 parent 1e1103f commit ed351df
Show file tree
Hide file tree
Showing 2 changed files with 115 additions and 4 deletions.
98 changes: 94 additions & 4 deletions src/sidebar/components/MarkdownView.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
import { useEffect, useMemo, useRef } from 'preact/hooks';
import { Popover } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';

import type { Mention } from '../../types/api';
import type { InvalidUsername } from '../helpers/mentions';
import { renderMentionTags } from '../helpers/mentions';
import { replaceLinksWithEmbeds } from '../media-embedder';
import { renderMathAndMarkdown } from '../render-markdown';
import StyledText from './StyledText';
import MentionPreviewContent from './mentions/MentionPreviewContent';

export type MarkdownViewProps = {
/** The string of markdown to display as HTML. */
markdown: string;
classes?: string;
style?: Record<string, string>;
mentions?: Mention[];

// Test seams
setTimeout_?: typeof setTimeout;
clearTimeout_?: typeof clearTimeout;
};

type PopoverContent = Mention | InvalidUsername | null;

/**
* A component which renders markdown as HTML and replaces recognized links
* with embedded video/audio.
Expand All @@ -22,14 +38,49 @@ export default function MarkdownView({
markdown,
classes,
style,
mentions,
mentions = [],
/* istanbul ignore next - test seam */
setTimeout_ = setTimeout,
/* istanbul ignore next - test seam */
clearTimeout_ = clearTimeout,
}: MarkdownViewProps) {
const html = useMemo(
() => (markdown ? renderMathAndMarkdown(markdown) : ''),
[markdown],
);
const content = useRef<HTMLDivElement | null>(null);

const mentionsPopoverAnchorRef = useRef<HTMLElement | null>(null);
const elementToMentionMap = useRef(
new Map<HTMLElement, Mention | InvalidUsername>(),
);
const [popoverContent, doSetPopoverContent] = useState<PopoverContent>(null);
/**
* This allows the content to be set with a small delay, so that popovers don't flickr
*/
const popoverContentTimeout = useRef<ReturnType<typeof setTimeout> | null>();
const setPopoverContent = useCallback(
(content: PopoverContent) => {
if (popoverContentTimeout.current) {
clearTimeout_(popoverContentTimeout.current);

Check warning on line 65 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L63-L65

Added lines #L63 - L65 were not covered by tests
}

const setContent = () => {
doSetPopoverContent(content);
popoverContentTimeout.current = null;

Check warning on line 70 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L68-L70

Added lines #L68 - L70 were not covered by tests
};

// Set the content immediately when resetting, so that there's no delay
// when hiding the popover, only when showing it
if (content === null) {
setContent();
} else {
popoverContentTimeout.current = setTimeout_(setContent, 400);

Check warning on line 78 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L75-L78

Added lines #L75 - L78 were not covered by tests
}
},
[clearTimeout_, setTimeout_],
);

useEffect(() => {
replaceLinksWithEmbeds(content.current!, {
// Make embeds the full width of the sidebar, unless the sidebar has been
Expand All @@ -40,7 +91,7 @@ export default function MarkdownView({
}, [markdown]);

useEffect(() => {
renderMentionTags(content.current!, mentions ?? []);
elementToMentionMap.current = renderMentionTags(content.current!, mentions);
}, [mentions]);

// NB: The following could be implemented by setting attribute props directly
Expand All @@ -50,14 +101,53 @@ export default function MarkdownView({
// a review in the future.
return (
<div className="w-full break-anywhere cursor-text">
<StyledText>
<StyledText
classes={classnames(
// A `relative` wrapper around the `Popover` component is needed for
// when the native Popover API is not supported.
'relative',
)}
>
<div
className={classes}
data-testid="markdown-text"
ref={content}
dangerouslySetInnerHTML={{ __html: html }}
style={style}
// React types do not define `onMouseEnterCapture`, but preact does
// eslint-disable-next-line react/no-unknown-property
onMouseEnterCapture={({ target }) => {
const element = target as HTMLElement;
const mention = elementToMentionMap.current.get(element);

Check warning on line 121 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L119-L121

Added lines #L119 - L121 were not covered by tests

if (mention) {
setPopoverContent(mention);
mentionsPopoverAnchorRef.current = element;

Check warning on line 125 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L123-L125

Added lines #L123 - L125 were not covered by tests
}
}}
// React types do not define `onMouseLeaveCapture`, but preact does
// eslint-disable-next-line react/no-unknown-property
onMouseLeaveCapture={() => {
setPopoverContent(null);
mentionsPopoverAnchorRef.current = null;

Check warning on line 132 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L130-L132

Added lines #L130 - L132 were not covered by tests
}}
/>
<Popover
open={!!popoverContent}
onClose={() => setPopoverContent(null)}

Check warning on line 137 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L137

Added line #L137 was not covered by tests
anchorElementRef={mentionsPopoverAnchorRef}
classes="px-3 py-2"
>
{typeof popoverContent === 'string' && (
<>

Check warning on line 142 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L142

Added line #L142 was not covered by tests
No user with username{' '}
<span className="font-bold">{popoverContent}</span> exists
</>
)}
{popoverContent !== null && typeof popoverContent === 'object' && (
<MentionPreviewContent mention={popoverContent} />

Check warning on line 148 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L148

Added line #L148 was not covered by tests
)}
</Popover>
</StyledText>
</div>
);
Expand Down
21 changes: 21 additions & 0 deletions src/sidebar/components/mentions/MentionPreviewContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Mention } from '../../../types/api';

export type MentionPreviewContent = {
mention: Mention;
};

/**
* Information to display on a Popover when hovering over a processed mention
*/
export default function MentionPreviewContent({
mention,
}: MentionPreviewContent) {
return (

Check warning on line 13 in src/sidebar/components/mentions/MentionPreviewContent.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/mentions/MentionPreviewContent.tsx#L12-L13

Added lines #L12 - L13 were not covered by tests
<div className="flex flex-col gap-y-1.5">
<div className="text-md font-bold">@{mention.username}</div>
{mention.display_name && (
<div className="text-color-text-light">{mention.display_name}</div>

Check warning on line 17 in src/sidebar/components/mentions/MentionPreviewContent.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/mentions/MentionPreviewContent.tsx#L16-L17

Added lines #L16 - L17 were not covered by tests
)}
</div>
);
}

0 comments on commit ed351df

Please sign in to comment.