diff --git a/web/chat/chat-constants.js b/web/chat/chat-constants.js --- a/web/chat/chat-constants.js +++ b/web/chat/chat-constants.js @@ -16,3 +16,12 @@ width: 30, height: 38, }; + +export const typeaheadStyle = { + tooltipWidth: 296, + tooltipMaxHeight: 268, + tooltipVerticalPadding: 16, + tooltipLeftOffset: 16, + tooltipTopOffset: 4, + rowHeight: 40, +}; diff --git a/web/chat/mention-utils.js b/web/chat/mention-utils.js --- a/web/chat/mention-utils.js +++ b/web/chat/mention-utils.js @@ -1,10 +1,26 @@ // @flow +import * as React from 'react'; + import { oldValidUsernameRegexString } from 'lib/shared/account-utils'; import SearchIndex from 'lib/shared/search-index'; import { stringForUserExplicit } from 'lib/shared/user-utils'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; +import { type InputState } from '../input/input-state'; +import { typeaheadStyle } from './chat-constants'; + +export type MentionSuggestionTooltipAction = { + +key: string, + +onClick: (SyntheticEvent) => mixed, + +actionButtonContent: React.Node, +}; + +export type TooltipPosition = { + +top: number, + +left: number, +}; + const mentionRegex: RegExp = new RegExp( `(?(?:^(?:.|\n)*\\s+)|^)@(?${oldValidUsernameRegexString})?$`, ); @@ -23,14 +39,10 @@ ); } -function getTextOffsets( - textarea: ?HTMLTextAreaElement, +function getCaretOffsets( + textarea: HTMLTextAreaElement, text: string, -): { topTextOffset: number, leftTextOffset: number } { - if (!textarea) { - return { topTextOffset: 0, leftTextOffset: 0 }; - } - +): { caretTopOffset: number, caretLeftOffset: number } { // 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 @@ -58,10 +70,83 @@ const { offsetTop, offsetLeft } = span; document.body?.removeChild(div); + const textareaWidth = parseInt(textareaStyle.getPropertyValue('width')); + + const leftOverflowOffset = + offsetLeft + typeaheadStyle.tooltipWidth > textareaWidth + ? offsetLeft + typeaheadStyle.tooltipWidth - textareaWidth + : 0; + return { - topTextOffset: offsetTop - textarea.scrollTop, - leftTextOffset: offsetLeft, + caretTopOffset: offsetTop - textarea.scrollTop, + caretLeftOffset: offsetLeft - leftOverflowOffset, }; } -export { mentionRegex, getTypeaheadUserSuggestions, getTextOffsets }; +function getTypeaheadTooltipActions( + inputState: InputState, + textarea: HTMLTextAreaElement, + suggestedUsers: $ReadOnlyArray, + matchedTextBefore: string, + wholeMatch: string, +): Array { + return suggestedUsers.map(suggestedUser => ({ + key: stringForUserExplicit(suggestedUser), + onClick: () => { + const newPrefixText = matchedTextBefore; + + const newSuffixText = inputState.draft.slice(wholeMatch.length); + + const newText = + newPrefixText + + '@' + + stringForUserExplicit(suggestedUser) + + (newSuffixText.length === 0 || newSuffixText[0] !== ' ' ? ' ' : '') + + newSuffixText; + + inputState.setDraft(newText); + inputState.setTextCursorPosition( + newText.length - + newSuffixText.length + + (newSuffixText[0] === '\n' ? 0 : 1), + ); + }, + actionButtonContent: stringForUserExplicit(suggestedUser), + })); +} + +function getTypeaheadTooltipPosition( + textarea: HTMLTextAreaElement, + actionsLength: number, + matchedTextBefore: string, +): TooltipPosition { + const { caretTopOffset, caretLeftOffset } = getCaretOffsets( + textarea, + matchedTextBefore, + ); + + const top: number = + textarea.getBoundingClientRect().top - + Math.min( + typeaheadStyle.tooltipVerticalPadding + + actionsLength * typeaheadStyle.rowHeight, + typeaheadStyle.tooltipMaxHeight, + ) - + typeaheadStyle.tooltipTopOffset + + caretTopOffset; + + const left: number = + textarea.getBoundingClientRect().left - + typeaheadStyle.tooltipLeftOffset + + caretLeftOffset; + + return { top, left }; +} + +export { + mentionRegex, + getTypeaheadUserSuggestions, + getCaretOffsets, + getTypeaheadTooltipActions, + getTypeaheadTooltipPosition, +};