diff --git a/lib/shared/typeahead-utils.js b/lib/shared/typeahead-utils.js index 4484efb57..8e3c56f55 100644 --- a/lib/shared/typeahead-utils.js +++ b/lib/shared/typeahead-utils.js @@ -1,29 +1,52 @@ // @flow import type { RelativeMemberInfo } from '../types/thread-types'; import SearchIndex from './search-index'; import { threadOtherMembers } from './thread-utils'; import { stringForUserExplicit } from './user-utils'; export type TypeaheadMatchedStrings = { +textBeforeAtSymbol: string, +usernamePrefix: string, }; function getTypeaheadUserSuggestions( userSearchIndex: SearchIndex, threadMembers: $ReadOnlyArray, viewerID: ?string, usernamePrefix: string, ): $ReadOnlyArray { const userIDs = userSearchIndex.getSearchResults(usernamePrefix); const usersInThread = threadOtherMembers(threadMembers, viewerID); return usersInThread .filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare(stringForUserExplicit(userB)), ); } -export { getTypeaheadUserSuggestions }; +function getNewTextAndSelection( + textBeforeAtSymbol: string, + entireText: string, + usernamePrefix: string, + user: RelativeMemberInfo, +): { + newText: string, + newSelectionStart: number, +} { + const totalMatchLength = + textBeforeAtSymbol.length + usernamePrefix.length + 1; // 1 for @ char + + let newSuffixText = entireText.slice(totalMatchLength); + newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; + + const newText = + textBeforeAtSymbol + '@' + stringForUserExplicit(user) + newSuffixText; + + const newSelectionStart = newText.length - newSuffixText.length + 1; + + return { newText, newSelectionStart }; +} + +export { getTypeaheadUserSuggestions, getNewTextAndSelection }; diff --git a/web/utils/typeahead-utils.js b/web/utils/typeahead-utils.js index b01b77f22..c831f291b 100644 --- a/web/utils/typeahead-utils.js +++ b/web/utils/typeahead-utils.js @@ -1,218 +1,210 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { oldValidUsernameRegexString } from 'lib/shared/account-utils'; +import { getNewTextAndSelection } from 'lib/shared/typeahead-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; + const { newText, newSelectionStart } = getNewTextAndSelection( + textBeforeAtSymbol, + inputStateDraft, + usernamePrefix, + suggestedUser, + ); inputStateSetDraft(newText); - inputStateSetTextCursorPosition( - newText.length - newSuffixText.length + 1, - ); + inputStateSetTextCursorPosition(newSelectionStart); }, 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, };