From 2177a7908064f0712dbcebe02071859b8c03a895 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 9 May 2023 18:23:56 -0700 Subject: [PATCH] Formatting: A few more changes --- .../v3/text_format/textformat-italic-bold.svg | 1 + .../v3/text_format/textformat-italic.svg | 1 - .../text_format/textformat-monospace-bold.svg | 1 + .../v3/text_format/textformat-monospace.svg | 1 - .../text_format/textformat-spoiler-bold.svg | 1 + .../v3/text_format/textformat-spoiler.svg | 1 - .../textformat-strikethrough-bold.svg | 1 + .../text_format/textformat-strikethrough.svg | 1 - stylesheets/components/CompositionInput.scss | 43 ++- ts/components/CompositionArea.tsx | 5 +- ts/components/CompositionInput.tsx | 76 +++++- ts/quill/formatting/menu.tsx | 248 ++++++++++-------- ts/quill/signal-clipboard/index.ts | 1 - ts/quill/util.ts | 92 +++++-- ts/state/selectors/conversations.ts | 6 + ts/state/smart/CompositionArea.tsx | 4 + ts/test-node/quill/util_test.ts | 69 +++++ 17 files changed, 393 insertions(+), 159 deletions(-) create mode 100644 images/icons/v3/text_format/textformat-italic-bold.svg delete mode 100644 images/icons/v3/text_format/textformat-italic.svg create mode 100644 images/icons/v3/text_format/textformat-monospace-bold.svg delete mode 100644 images/icons/v3/text_format/textformat-monospace.svg create mode 100644 images/icons/v3/text_format/textformat-spoiler-bold.svg delete mode 100644 images/icons/v3/text_format/textformat-spoiler.svg create mode 100644 images/icons/v3/text_format/textformat-strikethrough-bold.svg delete mode 100644 images/icons/v3/text_format/textformat-strikethrough.svg diff --git a/images/icons/v3/text_format/textformat-italic-bold.svg b/images/icons/v3/text_format/textformat-italic-bold.svg new file mode 100644 index 000000000..9dbdd87e1 --- /dev/null +++ b/images/icons/v3/text_format/textformat-italic-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/text_format/textformat-italic.svg b/images/icons/v3/text_format/textformat-italic.svg deleted file mode 100644 index 8bd829051..000000000 --- a/images/icons/v3/text_format/textformat-italic.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/images/icons/v3/text_format/textformat-monospace-bold.svg b/images/icons/v3/text_format/textformat-monospace-bold.svg new file mode 100644 index 000000000..46d8b10a1 --- /dev/null +++ b/images/icons/v3/text_format/textformat-monospace-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/text_format/textformat-monospace.svg b/images/icons/v3/text_format/textformat-monospace.svg deleted file mode 100644 index ecfa3e449..000000000 --- a/images/icons/v3/text_format/textformat-monospace.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/images/icons/v3/text_format/textformat-spoiler-bold.svg b/images/icons/v3/text_format/textformat-spoiler-bold.svg new file mode 100644 index 000000000..b6abac969 --- /dev/null +++ b/images/icons/v3/text_format/textformat-spoiler-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/text_format/textformat-spoiler.svg b/images/icons/v3/text_format/textformat-spoiler.svg deleted file mode 100644 index 3759e5ec4..000000000 --- a/images/icons/v3/text_format/textformat-spoiler.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/images/icons/v3/text_format/textformat-strikethrough-bold.svg b/images/icons/v3/text_format/textformat-strikethrough-bold.svg new file mode 100644 index 000000000..537c5f4ca --- /dev/null +++ b/images/icons/v3/text_format/textformat-strikethrough-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/text_format/textformat-strikethrough.svg b/images/icons/v3/text_format/textformat-strikethrough.svg deleted file mode 100644 index 27a06d93f..000000000 --- a/images/icons/v3/text_format/textformat-strikethrough.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/stylesheets/components/CompositionInput.scss b/stylesheets/components/CompositionInput.scss index f3f954068..c4c530d55 100644 --- a/stylesheets/components/CompositionInput.scss +++ b/stylesheets/components/CompositionInput.scss @@ -132,7 +132,8 @@ opacity: 0; transition: opacity ease 200ms; - @include popper-shadow(); + // The same box-shadow in popper-shadow mixin, just halved + box-shadow: 0px 4px 10px rgba(0, 0, 0, 30%), 0px 0px 4px rgba(0, 0, 0, 5%); @include light-theme() { background: $color-white; @@ -165,6 +166,26 @@ } } + &--active { + @include light-theme { + background-color: $color-gray-05; + } + @include dark-theme { + background-color: rgba($color-gray-45, 30%); + } + + @include mouse-mode { + &:hover { + background-color: $color-gray-15; + } + } + @include dark-mouse-mode { + &:hover { + background-color: rgba($color-gray-45, 50%); + } + } + } + &__popover { @include font-subtitle-bold; padding-block: 5px; @@ -217,13 +238,13 @@ &--italic { @include dark-theme { @include color-svg( - '../images/icons/v3/text_format/textformat-italic.svg', + '../images/icons/v3/text_format/textformat-italic-bold.svg', $color-gray-25 ); } @include light-theme { @include color-svg( - '../images/icons/v3/text_format/textformat-italic.svg', + '../images/icons/v3/text_format/textformat-italic-bold.svg', $color-gray-60 ); } @@ -232,13 +253,13 @@ &--strike { @include dark-theme { @include color-svg( - '../images/icons/v3/text_format/textformat-strikethrough.svg', + '../images/icons/v3/text_format/textformat-strikethrough-bold.svg', $color-gray-25 ); } @include light-theme { @include color-svg( - '../images/icons/v3/text_format/textformat-strikethrough.svg', + '../images/icons/v3/text_format/textformat-strikethrough-bold.svg', $color-gray-60 ); } @@ -247,13 +268,13 @@ &--monospace { @include dark-theme { @include color-svg( - '../images/icons/v3/text_format/textformat-monospace.svg', + '../images/icons/v3/text_format/textformat-monospace-bold.svg', $color-gray-25 ); } @include light-theme { @include color-svg( - '../images/icons/v3/text_format/textformat-monospace.svg', + '../images/icons/v3/text_format/textformat-monospace-bold.svg', $color-gray-60 ); } @@ -262,20 +283,20 @@ &--spoiler { @include dark-theme { @include color-svg( - '../images/icons/v3/text_format/textformat-spoiler.svg', + '../images/icons/v3/text_format/textformat-spoiler-bold.svg', $color-gray-25 ); } @include light-theme { @include color-svg( - '../images/icons/v3/text_format/textformat-spoiler.svg', + '../images/icons/v3/text_format/textformat-spoiler-bold.svg', $color-gray-60 ); } } - // Here we look at hover for the parent so the 2px border in between is active - // We can't use the mixins because .mouse-mode would wend up after the > + // Here we look at hover for the parent so the 2px border is a hover target + // Note: We can't use the mixins because .mouse-mode would end up after the > .mouse-mode #{$parent}:hover & { background-color: $color-gray-90; } diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 5cd5929f9..1a0f15eb8 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -105,6 +105,7 @@ export type OwnProps = Readonly<{ isSignalConversation?: boolean; recordingState: RecordingState; messageCompositionId: string; + shouldHidePopovers?: boolean; isSMSOnly?: boolean; left?: boolean; linkPreviewLoading: boolean; @@ -225,7 +226,6 @@ export function CompositionArea({ isDisabled, isSignalConversation, messageCompositionId, - showToast, pushPanelForConversation, platform, processAttachments, @@ -234,6 +234,8 @@ export function CompositionArea({ sendMultiMediaMessage, setComposerFocus, setQuoteByMessageId, + shouldHidePopovers, + showToast, theme, // AttachmentList @@ -931,6 +933,7 @@ export function CompositionArea({ onTextTooLong={onTextTooLong} platform={platform} sendCounter={sendCounter} + shouldHidePopovers={shouldHidePopovers} skinTone={skinTone} sortedGroupMembers={sortedGroupMembers} theme={theme} diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 2a830e904..fdefa78e4 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -47,6 +47,7 @@ import { SignalClipboard } from '../quill/signal-clipboard'; import { DirectionalBlot } from '../quill/block/blot'; import { getClassNamesFor } from '../util/getClassNamesFor'; import * as log from '../logging/log'; +import * as Errors from '../types/errors'; import { useRefMerger } from '../hooks/useRefMerger'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { StagedLinkPreview } from './conversation/StagedLinkPreview'; @@ -126,6 +127,7 @@ export type Props = Readonly<{ ): unknown; onScroll?: (ev: React.UIEvent) => void; platform: string; + shouldHidePopovers?: boolean; getQuotedMessage?(): unknown; clearQuotedMessage?(): unknown; linkPreviewLoading?: boolean; @@ -162,6 +164,7 @@ export function CompositionInput(props: Props): React.ReactElement { onSubmit, placeholder, platform, + shouldHidePopovers, skinTone, sendCounter, sortedGroupMembers, @@ -191,6 +194,8 @@ export function CompositionInput(props: Props): React.ReactElement { new MemberRepository() ); + const [isMouseDown, setIsMouseDown] = React.useState(false); + const generateDelta = ( text: string, bodyRanges: HydratedBodyRangesType @@ -393,6 +398,7 @@ export function CompositionInput(props: Props): React.ReactElement { isFormattingSpoilersFlagEnabled, isFormattingSpoilersFlagEnabled ); + const previousIsMouseDown = usePrevious(isMouseDown, isMouseDown); React.useEffect(() => { const formattingChanged = @@ -404,12 +410,18 @@ export function CompositionInput(props: Props): React.ReactElement { const spoilersFlagChanged = typeof previousFormattingSpoilersFlagEnabled === 'boolean' && previousFormattingSpoilersFlagEnabled !== isFormattingSpoilersFlagEnabled; + const mouseDownChanged = previousIsMouseDown !== isMouseDown; const quill = quillRef.current; - const changed = formattingChanged || flagChanged || spoilersFlagChanged; + const changed = + formattingChanged || + flagChanged || + spoilersFlagChanged || + mouseDownChanged; if (quill && changed) { quill.getModule('formattingMenu').updateOptions({ isMenuEnabled: isFormattingEnabled, + isMouseDown, isEnabled: isFormattingFlagEnabled, isSpoilersEnabled: isFormattingSpoilersFlagEnabled, }); @@ -422,9 +434,11 @@ export function CompositionInput(props: Props): React.ReactElement { isFormattingEnabled, isFormattingFlagEnabled, isFormattingSpoilersFlagEnabled, + isMouseDown, previousFormattingEnabled, previousFormattingFlagEnabled, previousFormattingSpoilersFlagEnabled, + previousIsMouseDown, quillRef, ]); @@ -813,6 +827,52 @@ export function CompositionInput(props: Props): React.ReactElement { const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName); + const onMouseDown = React.useCallback( + event => { + const target = event.target as HTMLElement; + try { + // If the user is actually clicking the format menu, we drop this event + if (target.closest('.module-composition-input__format-menu')) { + return; + } + setIsMouseDown(true); + } catch (error) { + log.error( + 'CompositionInput.onMouseDown: Failed to check event target', + Errors.toLogFormat(error) + ); + } + setIsMouseDown(true); + }, + [setIsMouseDown] + ); + const onMouseUp = React.useCallback( + () => setIsMouseDown(false), + [setIsMouseDown] + ); + const onMouseOut = React.useCallback( + event => { + const target = event.target as HTMLElement; + try { + // We get mouseout events for child objects of this one; filter 'em out! + if (!target.classList.contains(getClassName('__input'))) { + return; + } + setIsMouseDown(false); + } catch (error) { + log.error( + 'CompositionInput.onMouseOut: Failed to check class list', + Errors.toLogFormat(error) + ); + } + }, + [getClassName, setIsMouseDown] + ); + const onBlur = React.useCallback( + () => setIsMouseDown(false), + [setIsMouseDown] + ); + return ( @@ -823,6 +883,10 @@ export function CompositionInput(props: Props): React.ReactElement { ref={ref} data-testid="CompositionInput" data-enabled={disabled ? 'false' : 'true'} + onMouseDown={onMouseDown} + onMouseUp={onMouseUp} + onMouseOut={onMouseOut} + onBlur={onBlur} > {draftEditMessage && (
@@ -866,9 +930,13 @@ export function CompositionInput(props: Props): React.ReactElement { )} > {reactQuill} - {emojiCompletionElement} - {formattingChooserElement} - {mentionCompletionElement} + {shouldHidePopovers ? null : ( + <> + {emojiCompletionElement} + {mentionCompletionElement} + {formattingChooserElement} + + )}
)} diff --git a/ts/quill/formatting/menu.tsx b/ts/quill/formatting/menu.tsx index e55c70cf7..1dfc97388 100644 --- a/ts/quill/formatting/menu.tsx +++ b/ts/quill/formatting/menu.tsx @@ -8,7 +8,6 @@ import classNames from 'classnames'; import { Popper } from 'react-popper'; import { createPortal } from 'react-dom'; import type { VirtualElement } from '@popperjs/core'; -import { pick } from 'lodash'; import * as log from '../../logging/log'; import * as Errors from '../../types/errors'; @@ -16,7 +15,9 @@ import type { LocalizerType } from '../../types/Util'; import { handleOutsideClick } from '../../util/handleOutsideClick'; import { SECOND } from '../../util/durations/constants'; +const FADE_OUT_MS = 200; const BUTTON_HOVER_TIMEOUT = 2 * SECOND; +const MENU_TEXT_BUFFER = 12; // pixels // Note: Keyboard shortcuts are defined in the constructor below, and when using // below. They're also referenced in ShortcutGuide.tsx. @@ -29,6 +30,7 @@ const STRIKETHROUGH_CHAR = 'X'; type FormattingPickerOptions = { i18n: LocalizerType; isMenuEnabled: boolean; + isMouseDown?: boolean; isEnabled: boolean; isSpoilersEnabled: boolean; platform: string; @@ -43,40 +45,6 @@ export enum QuillFormattingStyle { spoiler = 'spoiler', } -function findMaximumRect(rects: DOMRectList): - | { - x: number; - y: number; - height: number; - width: number; - } - | undefined { - const first = rects[0]; - if (!first) { - return undefined; - } - - let result = pick(first, ['top', 'left', 'right', 'bottom']); - - for (let i = 1, max = rects.length; i < max; i += 1) { - const rect = rects[i]; - - result = { - top: Math.min(rect.top, result.top), - left: Math.min(rect.left, result.left), - bottom: Math.max(rect.bottom, result.bottom), - right: Math.max(rect.right, result.right), - }; - } - - return { - x: result.left, - y: result.top, - height: result.bottom - result.top, - width: result.right - result.left, - }; -} - function getMetaKey(platform: string, i18n: LocalizerType) { const isMacOS = platform === 'darwin'; @@ -87,16 +55,27 @@ function getMetaKey(platform: string, i18n: LocalizerType) { } export class FormattingMenu { + // Cache the results of our virtual elements's last rect calculation + lastRect: DOMRect | undefined; + + // Keep a references to our originally passed (or updated) options options: FormattingPickerOptions; + // Used to dismiss our menu if we click outside it outsideClickDestructor?: () => void; + // Maintaining a direct reference to quill quill: Quill; + // The element we hand to Popper to position the menu referenceElement: VirtualElement | undefined; + // The host for our portal root: HTMLDivElement; + // Timer to track an animated fade-out, then DOM removal + fadingOutTimeout?: NodeJS.Timeout; + constructor(quill: Quill, options: FormattingPickerOptions) { this.quill = quill; this.options = options; @@ -155,75 +134,105 @@ export class FormattingMenu { this.onEditorChange(); } + scheduleRemoval(): void { + if (this.fadingOutTimeout) { + return; + } + + this.fadingOutTimeout = setTimeout(() => { + this.referenceElement = undefined; + this.lastRect = undefined; + this.fadingOutTimeout = undefined; + this.render(); + }, FADE_OUT_MS); + + this.render(); + } + + cancelRemoval(): void { + if (this.fadingOutTimeout) { + clearTimeout(this.fadingOutTimeout); + this.fadingOutTimeout = undefined; + } + } + onEditorChange(): void { if (!this.options.isMenuEnabled) { - this.referenceElement = undefined; - this.render(); - + this.scheduleRemoval(); return; } const isFocused = this.quill.hasFocus(); if (!isFocused) { - this.referenceElement = undefined; - this.render(); - + this.scheduleRemoval(); return; } const quillSelection = this.quill.getSelection(); if (!quillSelection || quillSelection.length === 0) { - this.referenceElement = undefined; - } else { - // a virtual reference to the text we are trying to format - this.referenceElement = { - getBoundingClientRect() { - const selection = window.getSelection(); + this.scheduleRemoval(); + return; + } - // there's a selection and at least one range - if (selection != null && selection.rangeCount !== 0) { - // grab the first range, the one the user is actually on right now - const range = selection.getRangeAt(0); + // a virtual reference to the text we are trying to format + this.cancelRemoval(); + this.referenceElement = { + getBoundingClientRect: () => { + const selection = window.getSelection(); - const { activeElement } = document; - const editorElement = activeElement?.closest( - '.module-composition-input__input' - ); - const editorRect = editorElement?.getClientRects()[0]; - if (!editorRect) { - log.warn('No editor rect when showing formatting menu'); - return new DOMRect(); + // there's a selection and at least one range + if (selection != null && selection.rangeCount !== 0) { + // grab the first range, the one the user is actually on right now + const range = selection.getRangeAt(0); + + const { activeElement } = document; + const editorElement = activeElement?.closest( + '.module-composition-input__input' + ); + const editorRect = editorElement?.getClientRects()[0]; + if (!editorRect) { + // Note: this will happen when a user dismisses a panel; and if we don't + // cache here, the formatting menu will show in the very top-left + if (this.lastRect) { + return this.lastRect; } - - const rect = findMaximumRect(range.getClientRects()); - if (!rect) { - log.warn('No maximum rect when showing formatting menu'); - return new DOMRect(); - } - - // If we've scrolled down and the top of the composer text is invisible, above - // where the editor ends, we fix the popover so it stays connected to the - // visible editor. Important for the 'Cmd-A' scenario when scrolled down. - const updatedY = Math.max( - (editorRect.y || 0) - 10, - (rect.y || 0) - 10 - ); - const updatedHeight = rect.height + (rect.y - updatedY); - - return DOMRect.fromRect({ - x: rect.x, - y: updatedY, - height: updatedHeight, - width: rect.width, - }); + log.warn('No editor rect when showing formatting menu'); + return new DOMRect(); } - log.warn('No selection range when showing formatting menu'); - return new DOMRect(); - }, - }; - } + const rect = range.getBoundingClientRect(); + if (!rect) { + if (this.lastRect) { + return this.lastRect; + } + log.warn('No maximum rect when showing formatting menu'); + return new DOMRect(); + } + + // If we've scrolled down and the top of the composer text is invisible, above + // where the editor ends, we fix the popover so it stays connected to the + // visible editor. Important for the 'Cmd-A' scenario when scrolled down. + const updatedY = Math.max( + (editorRect.y || 0) - MENU_TEXT_BUFFER, + (rect.y || 0) - MENU_TEXT_BUFFER + ); + const updatedHeight = rect.height + (rect.y - updatedY); + + this.lastRect = DOMRect.fromRect({ + x: rect.x, + y: updatedY, + height: updatedHeight, + width: rect.width, + }); + + return this.lastRect; + } + + log.warn('No selection range when showing formatting menu'); + return new DOMRect(); + }, + }; this.render(); } @@ -279,22 +288,15 @@ export class FormattingMenu { const isStyleEnabledInSelection = this.isStyleEnabledInSelection.bind(this); const toggleForStyle = this.toggleForStyle.bind(this); const element = createPortal( - + {({ ref, style }) => { + const opacity = + style.transform && + !this.options.isMouseDown && + !this.fadingOutTimeout + ? 1 + : 0; + const [hasLongHovered, setHasLongHovered] = React.useState(false); const onLongHover = React.useCallback( @@ -308,14 +310,14 @@ export class FormattingMenu {
setHasLongHovered(false)} > boolean | undefined; + isActive: boolean | undefined; label: string; onLongHover: (value: boolean) => unknown; popupGuideText: string; @@ -413,6 +421,15 @@ function FormattingButton({ const timerRef = React.useRef(); const [isHovered, setIsHovered] = React.useState(false); + React.useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = undefined; + } + }; + }, []); + return ( <> {hasLongHovered && isHovered && buttonRef.current ? ( @@ -434,7 +451,12 @@ function FormattingButton({