From 2b37128066e139989dbfd612e2c2e3e5b889f759 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Wed, 16 Nov 2022 16:16:30 +0400 Subject: [PATCH] Message Input: Allow typing even when not focused (#2135) --- src/components/mediaViewer/MediaViewer.tsx | 11 +++ src/components/middle/composer/Composer.tsx | 2 + .../middle/composer/MessageInput.tsx | 67 +++++++++++++++++-- src/components/ui/Modal.tsx | 11 +++ src/util/directInputManager.ts | 13 ++++ 5 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 src/util/directInputManager.ts diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index c3262894b..995682319 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -31,6 +31,7 @@ import { IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment'; import { ANIMATION_END_DELAY } from '../../config'; import { MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions'; import windowSize from '../../util/windowSize'; +import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager'; import { animateClosing, animateOpening } from './helpers/ghostAnimation'; import { renderMessageText } from '../common/helpers/renderMessageText'; @@ -144,6 +145,16 @@ const MediaViewer: FC = ({ animationKey.current = selectedMediaIndex; } + useEffect(() => { + if (!isOpen) { + return undefined; + } + + disableDirectTextInput(); + + return enableDirectTextInput; + }, [isOpen]); + useEffect(() => { if (isVisible) { exitPictureInPictureIfNeeded(); diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index 89b8d9f87..417a08197 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -1237,6 +1237,7 @@ const Composer: FC = ({ )} = ({ } forcedPlaceholder={inlineBotHelp} canAutoFocus={isReady && !attachments.length} + noFocusInterception={attachments.length > 0} shouldSuppressFocus={IS_SINGLE_COLUMN_LAYOUT && isSymbolMenuOpen} shouldSuppressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen || isInlineBotTooltipOpen} onUpdate={setHtml} diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index 9dfd38958..e498a018d 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -8,7 +8,7 @@ import { getActions, withGlobal } from '../../../global'; import type { IAnchorPosition, ISettings } from '../../../types'; import { EDITABLE_INPUT_ID } from '../../../config'; -import { selectReplyingToId } from '../../../global/selectors'; +import { selectIsInSelectMode, selectReplyingToId } from '../../../global/selectors'; import { debounce } from '../../../util/schedulers'; import focusEditableElement from '../../../util/focusEditableElement'; import buildClassName from '../../../util/buildClassName'; @@ -16,6 +16,7 @@ import { IS_ANDROID, IS_EMOJI_SUPPORTED, IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV, } from '../../../util/environment'; import captureKeyboardListeners from '../../../util/captureKeyboardListeners'; +import { getIsDirectTextInputDisabled } from '../../../util/directInputManager'; import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps'; import useFlag from '../../../hooks/useFlag'; import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck'; @@ -40,6 +41,7 @@ type OwnProps = { html: string; placeholder: string; forcedPlaceholder?: string; + noFocusInterception?: boolean; canAutoFocus: boolean; shouldSuppressFocus?: boolean; shouldSuppressTextFormatter?: boolean; @@ -51,7 +53,7 @@ type OwnProps = { type StateProps = { replyingToId?: number; - noTabCapture?: boolean; + isSelectModeActive?: boolean; messageSendKeyCombo?: ISettings['messageSendKeyCombo']; }; @@ -62,6 +64,7 @@ const SELECTION_RECALCULATE_DELAY_MS = 260; const TEXT_FORMATTER_SAFE_AREA_PX = 90; // For some reason Safari inserts `
` after user removes text from input const SAFARI_BR = '
'; +const IGNORE_KEYS = ['Enter', 'PageUp', 'PageDown', 'Meta', 'Alt', 'Ctrl', 'ArrowDown', 'ArrowUp']; function clearSelection() { const selection = window.getSelection(); @@ -86,10 +89,11 @@ const MessageInput: FC = ({ placeholder, forcedPlaceholder, canAutoFocus, + noFocusInterception, shouldSuppressFocus, shouldSuppressTextFormatter, replyingToId, - noTabCapture, + isSelectModeActive, messageSendKeyCombo, onUpdate, onSuppressedFocus, @@ -363,19 +367,68 @@ const MessageInput: FC = ({ }, [chatId, focusInput, replyingToId, canAutoFocus]); useEffect(() => { - if (noTabCapture) { + if ( + !chatId + || editableInputId !== EDITABLE_INPUT_ID + || noFocusInterception + || (IS_TOUCH_ENV && IS_SINGLE_COLUMN_LAYOUT) + || isSelectModeActive + ) { return undefined; } + const handleDocumentKeyDown = (e: KeyboardEvent) => { + if (getIsDirectTextInputDisabled()) { + return; + } + + const { key } = e; + const target = e.target as HTMLElement | undefined; + + if (!target || IGNORE_KEYS.includes(key)) { + return; + } + + const input = inputRef.current!; + const isSelectionCollapsed = document.getSelection()?.isCollapsed; + + if ( + ((key.startsWith('Arrow') || (e.shiftKey && key === 'Shift')) && !isSelectionCollapsed) + || (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && target.tagName !== 'INPUT') + ) { + return; + } + + if ( + input + && target !== input + && target.tagName !== 'INPUT' + && !target.isContentEditable + ) { + focusEditableElement(input, true, true); + + const newEvent = new KeyboardEvent(e.type, e as any); + input.dispatchEvent(newEvent); + } + }; + + document.addEventListener('keydown', handleDocumentKeyDown, true); + + return () => { + document.removeEventListener('keydown', handleDocumentKeyDown, true); + }; + }, [chatId, editableInputId, isSelectModeActive, noFocusInterception]); + + useEffect(() => { const captureFirstTab = debounce((e: KeyboardEvent) => { - if (e.key === 'Tab') { + if (e.key === 'Tab' && !getIsDirectTextInputDisabled()) { e.preventDefault(); requestAnimationFrame(focusInput); } }, TAB_INDEX_PRIORITY_TIMEOUT, true, false); return captureKeyboardListeners({ onTab: captureFirstTab }); - }, [focusInput, noTabCapture]); + }, [focusInput]); useEffect(() => { const input = inputRef.current!; @@ -444,7 +497,7 @@ export default memo(withGlobal( return { messageSendKeyCombo, replyingToId: chatId && threadId ? selectReplyingToId(global, chatId, threadId) : undefined, - noTabCapture: global.pollModal.isOpen || global.payment.isPaymentModalOpen, + isSelectModeActive: selectIsInSelectMode(global), }; }, )(MessageInput)); diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 7b017a6cc..d5a69e5be 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -7,6 +7,7 @@ import type { TextPart } from '../../types'; import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import trapFocus from '../../util/trapFocus'; import buildClassName from '../../util/buildClassName'; +import { enableDirectTextInput, disableDirectTextInput } from '../../util/directInputManager'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import useShowTransition from '../../hooks/useShowTransition'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; @@ -63,6 +64,16 @@ const Modal: FC = ({ // eslint-disable-next-line no-null/no-null const modalRef = useRef(null); + useEffect(() => { + if (!isOpen) { + return undefined; + } + + disableDirectTextInput(); + + return enableDirectTextInput; + }, [isOpen]); + useEffect(() => (isOpen ? captureKeyboardListeners({ onEsc: onClose, onEnter }) : undefined), [isOpen, onClose, onEnter]); diff --git a/src/util/directInputManager.ts b/src/util/directInputManager.ts new file mode 100644 index 000000000..0be1ca7da --- /dev/null +++ b/src/util/directInputManager.ts @@ -0,0 +1,13 @@ +let counter = 0; + +export function disableDirectTextInput() { + counter += 1; +} + +export function enableDirectTextInput() { + counter -= 1; +} + +export function getIsDirectTextInputDisabled() { + return counter > 0; +}