diff --git a/web/chat/typeahead-tooltip.react.js b/web/chat/typeahead-tooltip.react.js index ec57c0884..fbae28b5d 100644 --- a/web/chat/typeahead-tooltip.react.js +++ b/web/chat/typeahead-tooltip.react.js @@ -1,163 +1,181 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; import { leastPositiveResidue } from 'lib/utils/math-utils'; import type { InputState } from '../input/input-state'; import { + getTypeaheadOverlayScroll, getTypeaheadTooltipActions, getTypeaheadTooltipButtons, getTypeaheadTooltipPosition, } from '../utils/typeahead-utils'; import type { TypeaheadMatchedStrings } from './chat-input-bar.react'; import css from './typeahead-tooltip.css'; export type TypeaheadTooltipProps = { +inputState: InputState, +textarea: HTMLTextAreaElement, +matchedStrings: TypeaheadMatchedStrings, +suggestedUsers: $ReadOnlyArray, }; function TypeaheadTooltip(props: TypeaheadTooltipProps): React.Node { const { inputState, textarea, matchedStrings, suggestedUsers } = props; const { textBeforeAtSymbol, usernamePrefix } = matchedStrings; const [isVisibleForAnimation, setIsVisibleForAnimation] = React.useState( false, ); const [ chosenPositionInOverlay, setChosenPositionInOverlay, ] = React.useState(0); + const overlayRef = React.useRef(); + React.useEffect(() => { setChosenPositionInOverlay(0); }, [suggestedUsers]); React.useEffect(() => { setIsVisibleForAnimation(true); return () => setIsVisibleForAnimation(false); }, []); const actions = React.useMemo( () => getTypeaheadTooltipActions({ inputStateDraft: inputState.draft, inputStateSetDraft: inputState.setDraft, inputStateSetTextCursorPosition: inputState.setTextCursorPosition, suggestedUsers, textBeforeAtSymbol, usernamePrefix, }), [ inputState.draft, inputState.setDraft, inputState.setTextCursorPosition, suggestedUsers, textBeforeAtSymbol, usernamePrefix, ], ); const tooltipPosition = React.useMemo( () => getTypeaheadTooltipPosition(textarea, actions.length, textBeforeAtSymbol), [textarea, actions.length, textBeforeAtSymbol], ); const tooltipPositionStyle = React.useMemo( () => ({ top: tooltipPosition.top, left: tooltipPosition.left, }), [tooltipPosition], ); const tooltipButtons = React.useMemo( () => getTypeaheadTooltipButtons( setChosenPositionInOverlay, chosenPositionInOverlay, actions, ), [setChosenPositionInOverlay, actions, chosenPositionInOverlay], ); const close = React.useCallback(() => { const setter = inputState.setTypeaheadState; setter({ canBeVisible: false, moveChoiceUp: null, moveChoiceDown: null, close: null, accept: null, }); }, [inputState.setTypeaheadState]); const accept = React.useCallback(() => { actions[chosenPositionInOverlay].execute(); close(); }, [actions, chosenPositionInOverlay, close]); const moveChoiceUp = React.useCallback(() => { if (actions.length === 0) { return; } setChosenPositionInOverlay(previousPosition => leastPositiveResidue(previousPosition - 1, actions.length), ); }, [setChosenPositionInOverlay, actions.length]); const moveChoiceDown = React.useCallback(() => { if (actions.length === 0) { return; } setChosenPositionInOverlay(previousPosition => leastPositiveResidue(previousPosition + 1, actions.length), ); }, [setChosenPositionInOverlay, actions.length]); React.useEffect(() => { const setter = inputState.setTypeaheadState; setter({ canBeVisible: true, moveChoiceUp, moveChoiceDown, close, accept, }); return close; }, [ close, accept, moveChoiceUp, moveChoiceDown, actions, inputState.setTypeaheadState, ]); + React.useEffect(() => { + const current = overlayRef.current; + if (current) { + const newScrollTop = getTypeaheadOverlayScroll( + current.scrollTop, + chosenPositionInOverlay, + ); + current.scrollTo(0, newScrollTop); + } + }, [chosenPositionInOverlay]); + if (suggestedUsers.length === 0) { return null; } const overlayClasses = classNames(css.suggestionsContainer, { [css.notVisible]: !isVisibleForAnimation, [css.visible]: isVisibleForAnimation, }); return ( -
+
{tooltipButtons}
); } export default TypeaheadTooltip; diff --git a/web/utils/typeahead-utils.js b/web/utils/typeahead-utils.js index 65dd0595f..b01b77f22 100644 --- a/web/utils/typeahead-utils.js +++ b/web/utils/typeahead-utils.js @@ -1,193 +1,218 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { oldValidUsernameRegexString } from 'lib/shared/account-utils'; import { stringForUserExplicit } from 'lib/shared/user-utils'; import type { SetState } from 'lib/types/hook-types'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; import { typeaheadStyle } from '../chat/chat-constants'; import css from '../chat/typeahead-tooltip.css'; import Button from '../components/button.react'; const webTypeaheadRegex: RegExp = new RegExp( `(?(?:^(?:.|\n)*\\s+)|^)@(?${oldValidUsernameRegexString})?$`, ); export type TypeaheadTooltipAction = { +key: string, +execute: () => mixed, +actionButtonContent: React.Node, }; export type TooltipPosition = { +top: number, +left: number, }; function getCaretOffsets( textarea: HTMLTextAreaElement, text: string, ): { caretTopOffset: number, caretLeftOffset: number } { if (!textarea) { return { caretTopOffset: 0, caretLeftOffset: 0 }; } // terribly hacky but it works I guess :D // we had to use it, as it's hard to count lines in textarea // and track cursor position within it as // lines can be wrapped into new lines without \n character // as result of overflow const textareaStyle: CSSStyleDeclaration = window.getComputedStyle( textarea, null, ); const div = document.createElement('div'); for (const styleName of textareaStyle) { div.style.setProperty(styleName, textareaStyle.getPropertyValue(styleName)); } div.style.display = 'inline-block'; div.style.position = 'absolute'; div.textContent = text; const span = document.createElement('span'); span.textContent = textarea.value.slice(text.length); div.appendChild(span); document.body?.appendChild(div); const { offsetTop, offsetLeft } = span; document.body?.removeChild(div); const textareaWidth = parseInt(textareaStyle.getPropertyValue('width')); const caretLeftOffset = offsetLeft + typeaheadStyle.tooltipWidth > textareaWidth ? textareaWidth - typeaheadStyle.tooltipWidth : offsetLeft; return { caretTopOffset: offsetTop - textarea.scrollTop, caretLeftOffset, }; } export type GetTypeaheadTooltipActionsParams = { +inputStateDraft: string, +inputStateSetDraft: (draft: string) => mixed, +inputStateSetTextCursorPosition: (newPosition: number) => mixed, +suggestedUsers: $ReadOnlyArray, +textBeforeAtSymbol: string, +usernamePrefix: string, }; function getTypeaheadTooltipActions( params: GetTypeaheadTooltipActionsParams, ): $ReadOnlyArray { const { inputStateDraft, inputStateSetDraft, inputStateSetTextCursorPosition, suggestedUsers, textBeforeAtSymbol, usernamePrefix, } = params; return suggestedUsers .filter( suggestedUser => stringForUserExplicit(suggestedUser) !== 'anonymous', ) .map(suggestedUser => ({ key: suggestedUser.id, execute: () => { const newPrefixText = textBeforeAtSymbol; const totalMatchLength = textBeforeAtSymbol.length + usernamePrefix.length + 1; // 1 for @ char let newSuffixText = inputStateDraft.slice(totalMatchLength); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; const newText = newPrefixText + '@' + stringForUserExplicit(suggestedUser) + newSuffixText; inputStateSetDraft(newText); inputStateSetTextCursorPosition( newText.length - newSuffixText.length + 1, ); }, actionButtonContent: stringForUserExplicit(suggestedUser), })); } function getTypeaheadTooltipButtons( setChosenPositionInOverlay: SetState, chosenPositionInOverlay: number, actions: $ReadOnlyArray, ): $ReadOnlyArray { return actions.map((action, idx) => { const { key, execute, actionButtonContent } = action; const buttonClasses = classNames(css.suggestion, { [css.suggestionHover]: idx === chosenPositionInOverlay, }); const onMouseMove: ( event: SyntheticEvent, ) => mixed = () => { setChosenPositionInOverlay(idx); }; return ( ); }); } +function getTypeaheadOverlayScroll( + currentScrollTop: number, + chosenActionPosition: number, +): number { + const upperButtonBoundary = chosenActionPosition * typeaheadStyle.rowHeight; + const lowerButtonBoundary = + (chosenActionPosition + 1) * typeaheadStyle.rowHeight; + + if (upperButtonBoundary < currentScrollTop) { + return upperButtonBoundary; + } else if ( + lowerButtonBoundary - typeaheadStyle.tooltipMaxHeight > + currentScrollTop + ) { + return ( + lowerButtonBoundary + + typeaheadStyle.tooltipVerticalPadding - + typeaheadStyle.tooltipMaxHeight + ); + } + + return currentScrollTop; +} + function getTypeaheadTooltipPosition( textarea: HTMLTextAreaElement, actionsLength: number, textBeforeAtSymbol: string, ): TooltipPosition { const { caretTopOffset, caretLeftOffset } = getCaretOffsets( textarea, textBeforeAtSymbol, ); const textareaBoundingClientRect = textarea.getBoundingClientRect(); const top: number = textareaBoundingClientRect.top - Math.min( typeaheadStyle.tooltipVerticalPadding + actionsLength * typeaheadStyle.rowHeight, typeaheadStyle.tooltipMaxHeight, ) - typeaheadStyle.tooltipTopOffset + caretTopOffset; const left: number = textareaBoundingClientRect.left - typeaheadStyle.tooltipLeftOffset + caretLeftOffset; return { top, left }; } export { webTypeaheadRegex, getCaretOffsets, getTypeaheadTooltipActions, getTypeaheadTooltipButtons, + getTypeaheadOverlayScroll, getTypeaheadTooltipPosition, }; diff --git a/web/utils/typeahead-utils.test.js b/web/utils/typeahead-utils.test.js new file mode 100644 index 000000000..9945bdaaa --- /dev/null +++ b/web/utils/typeahead-utils.test.js @@ -0,0 +1,32 @@ +// @flow + +import { typeaheadStyle } from '../chat/chat-constants'; +import { getTypeaheadOverlayScroll } from './typeahead-utils'; + +describe('getTypeaheadOverlayScroll', () => { + it( + 'should return the same scroll position when' + + 'it is already scrolled to the top and changing between 2nd button', + () => expect(getTypeaheadOverlayScroll(0, 1)).toEqual(0), + ); + + it( + 'should scroll down when it is scrolled to the top' + + 'and changing to button out of screen', + () => + expect(getTypeaheadOverlayScroll(0, 6)).toEqual( + (6 + 1) * typeaheadStyle.rowHeight + + typeaheadStyle.tooltipVerticalPadding - + typeaheadStyle.tooltipMaxHeight, + ), + ); + + it( + 'should scroll up when it is scrolled somewhere down' + + 'and changing to button out of screen to the top', + () => + expect(getTypeaheadOverlayScroll(500, 3)).toEqual( + 3 * typeaheadStyle.rowHeight, + ), + ); +});