translate/src/modules/comments/components/MentionList.tsx (151 lines of code) (raw):

import { Localized } from '@fluent/react'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { Range } from 'slate'; import { ReactEditor } from 'slate-react'; import type { MentionUser } from '~/api/user'; type Props = { editor: ReactEditor; index: number; onSelect(user: MentionUser): void; suggestedUsers: MentionUser[]; target: Range; }; export function MentionList({ editor, index, onSelect, suggestedUsers, target, }: Props): React.ReactPortal | null { const ref = useRef<HTMLDivElement>(null); const [scrollPosition, setScrollPosition] = useState(0); // Set position of mentions suggestions useLayoutEffect(() => { try { if (ref.current) { const range = ReactEditor.toDOMRange(editor, target); setMentionListStyle(ref.current, range); } } catch (error) { // https://github.com/mozilla/pontoon/issues/2298 // toDOMRange may fail on e.g. paste events, as the onChange may be // triggered before the DOM is updated. In that case, ignore the error // and let the next render fix things if necessary. } }, [editor, suggestedUsers.length, target, scrollPosition]); // Set scroll position values for Translation and Team Comment containers ~ // This allows for the mention suggestions to stay properly positioned // when the container scrolls. useEffect(() => { const handleScroll = (e: Event) => { const element = e.currentTarget as HTMLElement; setScrollPosition(element.scrollTop); }; const historyScroll = document.querySelector('#history-list'); const teamsScroll = document.querySelector('#react-tabs-3'); if (historyScroll || teamsScroll) { historyScroll?.addEventListener('scroll', handleScroll); teamsScroll?.addEventListener('scroll', handleScroll); return () => { historyScroll?.removeEventListener('scroll', handleScroll); teamsScroll?.removeEventListener('scroll', handleScroll); }; } }, []); const setStyleForHover = (ev: React.MouseEvent<HTMLDivElement>) => { ev.preventDefault(); ev.currentTarget.children[index].className = 'mention'; }; const removeStyleForHover = (ev: React.MouseEvent<HTMLDivElement>) => { ev.preventDefault(); ev.currentTarget.children[index].className = 'mention active-mention'; }; return document.body && suggestedUsers.length > 0 ? createPortal( <div ref={ref} className='comments-mention-list' onMouseEnter={setStyleForHover} onMouseLeave={removeStyleForHover} > {suggestedUsers.map((user, i) => ( <div key={user.name} className={i === index ? 'mention active-mention' : 'mention'} onMouseDown={(ev) => { ev.preventDefault(); onSelect(user); }} > <Localized id='comments-AddComment--mention-avatar-alt' attrs={{ alt: true }} > <span className='user-avatar'> <img src={user.gravatar} alt='User Avatar' width={22} height={22} /> </span> </Localized> <span className='name'>{user.name}</span> </div> ))} </div>, document.body, ) : null; } function setMentionListStyle(el: HTMLDivElement, domRange: globalThis.Range) { const rect = domRange.getBoundingClientRect(); // get team comments element, gain access to its measurements, and verify // if it is active const teamCommentsEl = document.querySelector('.top'); const teamCommentsRect = teamCommentsEl?.getBoundingClientRect(); const teamCommentsActive = teamCommentsEl?.contains(document.activeElement); // get translation comments element, gain access to its measurements, and verify // if it is active const translateCommentsEl = document.querySelector('.history'); const translateCommentsRect = translateCommentsEl?.getBoundingClientRect(); const translateCommentsActive = translateCommentsEl?.contains( document.activeElement, ); // get editor menu element and find its height to determine when comment editor goes above // the editor menu in order to hide suggestions element const editorMenuHeight = document.querySelector('.editor-menu')?.clientHeight ?? 0; // get tab index element and find its height to use when determining if suggestions // element overflows the team comments container const tabIndexHeight = document.querySelector('.react-tabs__tab-list')?.clientHeight ?? 0; // get comment editor element and find measurements of values needed to adjust // the suggestions element to the correct position const commentEditor = document.querySelector( '.comments-list .add-comment .comment-editor', ); const ceStyle = commentEditor ? window.getComputedStyle(commentEditor) : null; const ceLineHeight = parseInt(ceStyle?.lineHeight ?? '0'); const ceTopPadding = parseInt(ceStyle?.paddingTop ?? '0'); const ceBottomPadding = parseInt(ceStyle?.paddingBottom ?? '0'); const ceSpanHeight = document.querySelector<HTMLElement>( '.comments-list .add-comment .comment-editor p span', )?.offsetHeight ?? 0; // add value of comment editor bottom padding and span height to properly position suggestions element const setTopAdjustment = ceBottomPadding + ceSpanHeight; // add value of comment editor top padding and difference between line height and span height // of the top half of the comment editor to correctly size the height of the suggestions const suggestionsHeightAdjustment = ceTopPadding + (ceLineHeight - ceSpanHeight) / 2; let setTop = rect.top + window.pageYOffset + setTopAdjustment; let setLeft = rect.left + window.pageXOffset; // If suggestions overflow the window or teams container height then adjust the // position so they display above the comment const suggestionsHeight = el.clientHeight + suggestionsHeightAdjustment; const teamCommentsOverflow = !teamCommentsRect ? false : setTop + el.clientHeight - tabIndexHeight > teamCommentsRect.height; if ( (teamCommentsActive && teamCommentsOverflow) || setTop + suggestionsHeight > window.innerHeight ) { setTop = rect.top + window.pageYOffset - suggestionsHeight; } // If suggestions in team comments scroll below or suggestions in translation // comments scroll above the next section or overflow the window then hide the suggestions if ( (teamCommentsRect && teamCommentsActive && setTop + suggestionsHeight - editorMenuHeight > teamCommentsRect.height) || (translateCommentsRect && translateCommentsActive && (rect.top < translateCommentsRect.top || setTop + suggestionsHeight > window.innerHeight)) ) { el.style.display = 'none'; } else { // If suggestions overflow the window width in team comments or the right side of the // translations comments then adjust the position so they display to the left of the mention const suggestionsWidth = el.clientWidth; const translateCommentsOverflow = !translateCommentsRect ? false : setLeft + suggestionsWidth > translateCommentsRect.right; if ( setLeft + suggestionsWidth > window.innerWidth || (translateCommentsActive && translateCommentsOverflow) ) { setLeft = rect.right - suggestionsWidth; } el.style.display = 'block'; el.style.top = `${setTop}px`; el.style.left = `${setLeft}px`; } }