diff --git a/web/chat/chat-constants.js b/web/chat/chat-constants.js index 9b3fdc9a1..549ccf436 100644 --- a/web/chat/chat-constants.js +++ b/web/chat/chat-constants.js @@ -1,18 +1,27 @@ // @flow export const tooltipStyle = { paddingLeft: 5, paddingRight: 5, rowGap: 3, }; export const tooltipLabelStyle = { padding: 6, height: 20, }; export const tooltipButtonStyle = { paddingLeft: 6, paddingRight: 6, 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 index 551d8efa5..47f98d45f 100644 --- a/web/chat/mention-utils.js +++ b/web/chat/mention-utils.js @@ -1,67 +1,159 @@ // @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'; const mentionRegex: RegExp = new RegExp( `(?(?:^(?:.|\n)*\\s+)|^)@(?${oldValidUsernameRegexString})?$`, ); +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, +}; + function getTypeaheadUserSuggestions( userSearchIndex: SearchIndex, usersInThread: $ReadOnlyArray, typedPrefix: string, ): $ReadOnlyArray { const userIDs = userSearchIndex.getSearchResults(typedPrefix); return usersInThread .filter(user => typedPrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare(stringForUserExplicit(userB)), ); } function getCaretOffsets( - textarea: ?HTMLTextAreaElement, + 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: offsetLeft, + caretLeftOffset, }; } -export { mentionRegex, getTypeaheadUserSuggestions, getCaretOffsets }; +function getTypeaheadTooltipActions( + inputState: InputState, + textarea: HTMLTextAreaElement, + suggestedUsers: $ReadOnlyArray, + matchedTextBefore: string, + wholeMatch: string, +): $ReadOnlyArray { + return suggestedUsers + .filter( + suggestedUser => stringForUserExplicit(suggestedUser) !== 'anonymous', + ) + .map(suggestedUser => ({ + key: suggestedUser.id, + onClick: () => { + const newPrefixText = matchedTextBefore; + + let newSuffixText = inputState.draft.slice(wholeMatch.length); + newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; + + const newText = + newPrefixText + + '@' + + stringForUserExplicit(suggestedUser) + + newSuffixText; + + inputState.setDraft(newText); + inputState.setTextCursorPosition( + newText.length - newSuffixText.length + 1, + ); + }, + actionButtonContent: stringForUserExplicit(suggestedUser), + })); +} +function getTypeaheadTooltipPosition( + textarea: HTMLTextAreaElement, + actionsLength: number, + matchedTextBefore: string, +): TooltipPosition { + const { caretTopOffset, caretLeftOffset } = getCaretOffsets( + textarea, + matchedTextBefore, + ); + + 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 { + mentionRegex, + getTypeaheadUserSuggestions, + getCaretOffsets, + getTypeaheadTooltipActions, + getTypeaheadTooltipPosition, +};