Formatting: A few more changes

This commit is contained in:
Scott Nonnenberg 2023-05-09 18:23:56 -07:00 committed by GitHub
parent b4caf67bf9
commit 2177a79080
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 393 additions and 159 deletions

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.917 16.667c0 .46-.373.833-.834.833H6.146a.833.833 0 0 1 0-1.667h2.006l2.005-11.666H8.23a.833.833 0 0 1 0-1.667h5.938a.833.833 0 0 1 0 1.667H12.16l-2.006 11.666h1.928c.46 0 .834.373.834.834Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 298 B

View file

@ -1 +0,0 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.813 16.667a.73.73 0 0 1-.73.729H6.146a.73.73 0 0 1 0-1.459H8.24l2.04-11.875H8.23a.73.73 0 1 1 0-1.458h5.937a.73.73 0 1 1 0 1.458h-2.094l-2.041 11.875h2.051a.73.73 0 0 1 .73.73Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 285 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m6.41 5.604.151 2.894.002.044v8.229a.937.937 0 1 1-1.875 0V3.54a1.04 1.04 0 0 1 1.041-1.041h.584c.436 0 .826.272.977.68L10 10.514l2.71-7.332c.15-.41.54-.681.977-.681h.584c.575 0 1.041.466 1.041 1.042V16.77a.937.937 0 1 1-1.874 0V8.498l.153-2.894-2.81 7.602a.834.834 0 0 1-1.563 0L6.41 5.604Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 396 B

View file

@ -1 +0,0 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m6.301 5.011.156 2.971a.73.73 0 0 1 .001.039v8.75a.833.833 0 0 1-1.666 0V3.54c0-.517.42-.937.937-.937h.584c.393 0 .744.245.88.613L10 10.813l2.807-7.596a.937.937 0 0 1 .88-.613h.584c.518 0 .937.42.937.938V16.77a.833.833 0 0 1-1.666 0V8.02a.7.7 0 0 1 0-.039l.157-2.97-3.015 8.157a.73.73 0 0 1-1.368 0L6.301 5.011Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 416 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.363 2.273a.833.833 0 0 1 .114 1.173L4.81 17.613a.833.833 0 0 1-1.287-1.06L15.19 2.388a.833.833 0 0 1 1.173-.114Zm-5.147 0a.833.833 0 0 1 .114 1.173l-6.52 7.917a.833.833 0 0 1-1.287-1.06l6.52-7.916a.833.833 0 0 1 1.173-.114Zm5.261 7.423a.833.833 0 0 0-1.287-1.059l-6.52 7.917a.833.833 0 0 0 1.287 1.06l6.52-7.918ZM6.069 2.273a.833.833 0 0 1 .113 1.173L4.81 5.113a.833.833 0 1 1-1.287-1.06l1.373-1.666a.833.833 0 0 1 1.173-.114Zm10.408 13.674a.833.833 0 1 0-1.287-1.06l-1.373 1.667a.833.833 0 0 0 1.287 1.06l1.373-1.668Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 627 B

View file

@ -1 +0,0 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.297 2.354a.73.73 0 0 1 .1 1.026L4.73 17.547a.73.73 0 1 1-1.126-.927L15.27 2.453a.73.73 0 0 1 1.027-.1Zm-5.147 0a.73.73 0 0 1 .1 1.026l-6.52 7.917a.73.73 0 1 1-1.126-.927l6.52-7.917a.73.73 0 0 1 1.026-.1Zm5.246 7.276a.73.73 0 1 0-1.126-.927L8.75 16.62a.73.73 0 0 0 1.127.927l6.52-7.917ZM6.003 2.354a.73.73 0 0 1 .1 1.026L4.73 5.047a.73.73 0 0 1-1.126-.927l1.372-1.667a.73.73 0 0 1 1.027-.1ZM16.396 15.88a.73.73 0 0 0-1.126-.927l-1.372 1.667a.73.73 0 0 0 1.126.927l1.372-1.667Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 584 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.35 3.406c.92-.732 2.186-1.114 3.65-1.114 2.6 0 4.57 1.228 4.961 3.103a.932.932 0 0 1-.914 1.121.94.94 0 0 1-.921-.759c-.16-.81-1.257-1.799-3.126-1.799-1.074 0-1.86.306-2.364.73-.496.418-.76.985-.76 1.635 0 .575.172 1.006.584 1.385H5.101a3.851 3.851 0 0 1-.205-1.279c0-1.214.521-2.28 1.455-3.023Zm6.722 8.886h2.225c.078.294.12.613.12.959 0 1.635-.767 2.796-1.877 3.512-1.069.688-2.416.945-3.646.945-1.355 0-2.513-.274-3.42-.835a4.107 4.107 0 0 1-1.825-2.415.932.932 0 0 1 .897-1.188c.435 0 .794.296.903.689.326 1.185 1.586 2.083 3.444 2.083.988 0 1.9-.281 2.543-.757.625-.462 1.002-1.104 1.002-1.928 0-.383-.091-.66-.23-.88a1.543 1.543 0 0 0-.136-.185Zm3.178-1.459a.833.833 0 0 0 0-1.666H3.75a.833.833 0 1 0 0 1.666h12.5Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 828 B

View file

@ -1 +0,0 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.416 3.487C7.312 2.774 8.553 2.396 10 2.396c2.573 0 4.482 1.213 4.86 3.02a.828.828 0 0 1-.813.996.836.836 0 0 1-.82-.675c-.172-.878-1.33-1.883-3.227-1.883-1.093 0-1.906.312-2.431.755a2.187 2.187 0 0 0-.798 1.714c0 .61.188 1.072.634 1.476.083.075.176.15.28.222H5.344A3.595 3.595 0 0 1 5 6.429c0-1.182.507-2.218 1.416-2.942Zm6.483 8.493h2.187c.146.374.226.796.226 1.271 0 1.597-.746 2.726-1.83 3.424-1.045.674-2.37.93-3.589.93-1.34 0-2.478-.273-3.365-.82A4.003 4.003 0 0 1 4.75 14.43a.828.828 0 0 1 .797-1.055c.387 0 .706.262.802.611.343 1.245 1.657 2.16 3.545 2.16 1.006 0 1.94-.286 2.605-.777.648-.479 1.044-1.15 1.044-2.011 0-.402-.096-.699-.245-.937a1.857 1.857 0 0 0-.398-.442Zm3.351-1.25a.73.73 0 0 0 0-1.46H3.75a.73.73 0 1 0 0 1.46h12.5Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 849 B

View file

@ -132,7 +132,8 @@
opacity: 0; opacity: 0;
transition: opacity ease 200ms; 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() { @include light-theme() {
background: $color-white; 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 { &__popover {
@include font-subtitle-bold; @include font-subtitle-bold;
padding-block: 5px; padding-block: 5px;
@ -217,13 +238,13 @@
&--italic { &--italic {
@include dark-theme { @include dark-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/text_format/textformat-italic.svg', '../images/icons/v3/text_format/textformat-italic-bold.svg',
$color-gray-25 $color-gray-25
); );
} }
@include light-theme { @include light-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/text_format/textformat-italic.svg', '../images/icons/v3/text_format/textformat-italic-bold.svg',
$color-gray-60 $color-gray-60
); );
} }
@ -232,13 +253,13 @@
&--strike { &--strike {
@include dark-theme { @include dark-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/text_format/textformat-strikethrough.svg', '../images/icons/v3/text_format/textformat-strikethrough-bold.svg',
$color-gray-25 $color-gray-25
); );
} }
@include light-theme { @include light-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/text_format/textformat-strikethrough.svg', '../images/icons/v3/text_format/textformat-strikethrough-bold.svg',
$color-gray-60 $color-gray-60
); );
} }
@ -247,13 +268,13 @@
&--monospace { &--monospace {
@include dark-theme { @include dark-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/text_format/textformat-monospace.svg', '../images/icons/v3/text_format/textformat-monospace-bold.svg',
$color-gray-25 $color-gray-25
); );
} }
@include light-theme { @include light-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/text_format/textformat-monospace.svg', '../images/icons/v3/text_format/textformat-monospace-bold.svg',
$color-gray-60 $color-gray-60
); );
} }
@ -262,20 +283,20 @@
&--spoiler { &--spoiler {
@include dark-theme { @include dark-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/text_format/textformat-spoiler.svg', '../images/icons/v3/text_format/textformat-spoiler-bold.svg',
$color-gray-25 $color-gray-25
); );
} }
@include light-theme { @include light-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/text_format/textformat-spoiler.svg', '../images/icons/v3/text_format/textformat-spoiler-bold.svg',
$color-gray-60 $color-gray-60
); );
} }
} }
// Here we look at hover for the parent so the 2px border in between is active // Here we look at hover for the parent so the 2px border is a hover target
// We can't use the mixins because .mouse-mode would wend up after the > // Note: We can't use the mixins because .mouse-mode would end up after the >
.mouse-mode #{$parent}:hover & { .mouse-mode #{$parent}:hover & {
background-color: $color-gray-90; background-color: $color-gray-90;
} }

View file

@ -105,6 +105,7 @@ export type OwnProps = Readonly<{
isSignalConversation?: boolean; isSignalConversation?: boolean;
recordingState: RecordingState; recordingState: RecordingState;
messageCompositionId: string; messageCompositionId: string;
shouldHidePopovers?: boolean;
isSMSOnly?: boolean; isSMSOnly?: boolean;
left?: boolean; left?: boolean;
linkPreviewLoading: boolean; linkPreviewLoading: boolean;
@ -225,7 +226,6 @@ export function CompositionArea({
isDisabled, isDisabled,
isSignalConversation, isSignalConversation,
messageCompositionId, messageCompositionId,
showToast,
pushPanelForConversation, pushPanelForConversation,
platform, platform,
processAttachments, processAttachments,
@ -234,6 +234,8 @@ export function CompositionArea({
sendMultiMediaMessage, sendMultiMediaMessage,
setComposerFocus, setComposerFocus,
setQuoteByMessageId, setQuoteByMessageId,
shouldHidePopovers,
showToast,
theme, theme,
// AttachmentList // AttachmentList
@ -931,6 +933,7 @@ export function CompositionArea({
onTextTooLong={onTextTooLong} onTextTooLong={onTextTooLong}
platform={platform} platform={platform}
sendCounter={sendCounter} sendCounter={sendCounter}
shouldHidePopovers={shouldHidePopovers}
skinTone={skinTone} skinTone={skinTone}
sortedGroupMembers={sortedGroupMembers} sortedGroupMembers={sortedGroupMembers}
theme={theme} theme={theme}

View file

@ -47,6 +47,7 @@ import { SignalClipboard } from '../quill/signal-clipboard';
import { DirectionalBlot } from '../quill/block/blot'; import { DirectionalBlot } from '../quill/block/blot';
import { getClassNamesFor } from '../util/getClassNamesFor'; import { getClassNamesFor } from '../util/getClassNamesFor';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { useRefMerger } from '../hooks/useRefMerger'; import { useRefMerger } from '../hooks/useRefMerger';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { StagedLinkPreview } from './conversation/StagedLinkPreview';
@ -126,6 +127,7 @@ export type Props = Readonly<{
): unknown; ): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void; onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
platform: string; platform: string;
shouldHidePopovers?: boolean;
getQuotedMessage?(): unknown; getQuotedMessage?(): unknown;
clearQuotedMessage?(): unknown; clearQuotedMessage?(): unknown;
linkPreviewLoading?: boolean; linkPreviewLoading?: boolean;
@ -162,6 +164,7 @@ export function CompositionInput(props: Props): React.ReactElement {
onSubmit, onSubmit,
placeholder, placeholder,
platform, platform,
shouldHidePopovers,
skinTone, skinTone,
sendCounter, sendCounter,
sortedGroupMembers, sortedGroupMembers,
@ -191,6 +194,8 @@ export function CompositionInput(props: Props): React.ReactElement {
new MemberRepository() new MemberRepository()
); );
const [isMouseDown, setIsMouseDown] = React.useState<boolean>(false);
const generateDelta = ( const generateDelta = (
text: string, text: string,
bodyRanges: HydratedBodyRangesType bodyRanges: HydratedBodyRangesType
@ -393,6 +398,7 @@ export function CompositionInput(props: Props): React.ReactElement {
isFormattingSpoilersFlagEnabled, isFormattingSpoilersFlagEnabled,
isFormattingSpoilersFlagEnabled isFormattingSpoilersFlagEnabled
); );
const previousIsMouseDown = usePrevious(isMouseDown, isMouseDown);
React.useEffect(() => { React.useEffect(() => {
const formattingChanged = const formattingChanged =
@ -404,12 +410,18 @@ export function CompositionInput(props: Props): React.ReactElement {
const spoilersFlagChanged = const spoilersFlagChanged =
typeof previousFormattingSpoilersFlagEnabled === 'boolean' && typeof previousFormattingSpoilersFlagEnabled === 'boolean' &&
previousFormattingSpoilersFlagEnabled !== isFormattingSpoilersFlagEnabled; previousFormattingSpoilersFlagEnabled !== isFormattingSpoilersFlagEnabled;
const mouseDownChanged = previousIsMouseDown !== isMouseDown;
const quill = quillRef.current; const quill = quillRef.current;
const changed = formattingChanged || flagChanged || spoilersFlagChanged; const changed =
formattingChanged ||
flagChanged ||
spoilersFlagChanged ||
mouseDownChanged;
if (quill && changed) { if (quill && changed) {
quill.getModule('formattingMenu').updateOptions({ quill.getModule('formattingMenu').updateOptions({
isMenuEnabled: isFormattingEnabled, isMenuEnabled: isFormattingEnabled,
isMouseDown,
isEnabled: isFormattingFlagEnabled, isEnabled: isFormattingFlagEnabled,
isSpoilersEnabled: isFormattingSpoilersFlagEnabled, isSpoilersEnabled: isFormattingSpoilersFlagEnabled,
}); });
@ -422,9 +434,11 @@ export function CompositionInput(props: Props): React.ReactElement {
isFormattingEnabled, isFormattingEnabled,
isFormattingFlagEnabled, isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled, isFormattingSpoilersFlagEnabled,
isMouseDown,
previousFormattingEnabled, previousFormattingEnabled,
previousFormattingFlagEnabled, previousFormattingFlagEnabled,
previousFormattingSpoilersFlagEnabled, previousFormattingSpoilersFlagEnabled,
previousIsMouseDown,
quillRef, quillRef,
]); ]);
@ -813,6 +827,52 @@ export function CompositionInput(props: Props): React.ReactElement {
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName); 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 ( return (
<Manager> <Manager>
<Reference> <Reference>
@ -823,6 +883,10 @@ export function CompositionInput(props: Props): React.ReactElement {
ref={ref} ref={ref}
data-testid="CompositionInput" data-testid="CompositionInput"
data-enabled={disabled ? 'false' : 'true'} data-enabled={disabled ? 'false' : 'true'}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseOut={onMouseOut}
onBlur={onBlur}
> >
{draftEditMessage && ( {draftEditMessage && (
<div className={getClassName('__editing-message')}> <div className={getClassName('__editing-message')}>
@ -866,9 +930,13 @@ export function CompositionInput(props: Props): React.ReactElement {
)} )}
> >
{reactQuill} {reactQuill}
{emojiCompletionElement} {shouldHidePopovers ? null : (
{formattingChooserElement} <>
{mentionCompletionElement} {emojiCompletionElement}
{mentionCompletionElement}
{formattingChooserElement}
</>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -8,7 +8,6 @@ import classNames from 'classnames';
import { Popper } from 'react-popper'; import { Popper } from 'react-popper';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { VirtualElement } from '@popperjs/core'; import type { VirtualElement } from '@popperjs/core';
import { pick } from 'lodash';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
@ -16,7 +15,9 @@ import type { LocalizerType } from '../../types/Util';
import { handleOutsideClick } from '../../util/handleOutsideClick'; import { handleOutsideClick } from '../../util/handleOutsideClick';
import { SECOND } from '../../util/durations/constants'; import { SECOND } from '../../util/durations/constants';
const FADE_OUT_MS = 200;
const BUTTON_HOVER_TIMEOUT = 2 * SECOND; const BUTTON_HOVER_TIMEOUT = 2 * SECOND;
const MENU_TEXT_BUFFER = 12; // pixels
// Note: Keyboard shortcuts are defined in the constructor below, and when using // Note: Keyboard shortcuts are defined in the constructor below, and when using
// <FormattingButton /> below. They're also referenced in ShortcutGuide.tsx. // <FormattingButton /> below. They're also referenced in ShortcutGuide.tsx.
@ -29,6 +30,7 @@ const STRIKETHROUGH_CHAR = 'X';
type FormattingPickerOptions = { type FormattingPickerOptions = {
i18n: LocalizerType; i18n: LocalizerType;
isMenuEnabled: boolean; isMenuEnabled: boolean;
isMouseDown?: boolean;
isEnabled: boolean; isEnabled: boolean;
isSpoilersEnabled: boolean; isSpoilersEnabled: boolean;
platform: string; platform: string;
@ -43,40 +45,6 @@ export enum QuillFormattingStyle {
spoiler = 'spoiler', 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) { function getMetaKey(platform: string, i18n: LocalizerType) {
const isMacOS = platform === 'darwin'; const isMacOS = platform === 'darwin';
@ -87,16 +55,27 @@ function getMetaKey(platform: string, i18n: LocalizerType) {
} }
export class FormattingMenu { 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; options: FormattingPickerOptions;
// Used to dismiss our menu if we click outside it
outsideClickDestructor?: () => void; outsideClickDestructor?: () => void;
// Maintaining a direct reference to quill
quill: Quill; quill: Quill;
// The element we hand to Popper to position the menu
referenceElement: VirtualElement | undefined; referenceElement: VirtualElement | undefined;
// The host for our portal
root: HTMLDivElement; root: HTMLDivElement;
// Timer to track an animated fade-out, then DOM removal
fadingOutTimeout?: NodeJS.Timeout;
constructor(quill: Quill, options: FormattingPickerOptions) { constructor(quill: Quill, options: FormattingPickerOptions) {
this.quill = quill; this.quill = quill;
this.options = options; this.options = options;
@ -155,75 +134,105 @@ export class FormattingMenu {
this.onEditorChange(); 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 { onEditorChange(): void {
if (!this.options.isMenuEnabled) { if (!this.options.isMenuEnabled) {
this.referenceElement = undefined; this.scheduleRemoval();
this.render();
return; return;
} }
const isFocused = this.quill.hasFocus(); const isFocused = this.quill.hasFocus();
if (!isFocused) { if (!isFocused) {
this.referenceElement = undefined; this.scheduleRemoval();
this.render();
return; return;
} }
const quillSelection = this.quill.getSelection(); const quillSelection = this.quill.getSelection();
if (!quillSelection || quillSelection.length === 0) { if (!quillSelection || quillSelection.length === 0) {
this.referenceElement = undefined; this.scheduleRemoval();
} else { return;
// a virtual reference to the text we are trying to format }
this.referenceElement = {
getBoundingClientRect() {
const selection = window.getSelection();
// there's a selection and at least one range // a virtual reference to the text we are trying to format
if (selection != null && selection.rangeCount !== 0) { this.cancelRemoval();
// grab the first range, the one the user is actually on right now this.referenceElement = {
const range = selection.getRangeAt(0); getBoundingClientRect: () => {
const selection = window.getSelection();
const { activeElement } = document; // there's a selection and at least one range
const editorElement = activeElement?.closest( if (selection != null && selection.rangeCount !== 0) {
'.module-composition-input__input' // grab the first range, the one the user is actually on right now
); const range = selection.getRangeAt(0);
const editorRect = editorElement?.getClientRects()[0];
if (!editorRect) { const { activeElement } = document;
log.warn('No editor rect when showing formatting menu'); const editorElement = activeElement?.closest(
return new DOMRect(); '.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;
} }
log.warn('No editor rect when showing formatting menu');
const rect = findMaximumRect(range.getClientRects()); return new DOMRect();
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 selection range when showing formatting menu'); const rect = range.getBoundingClientRect();
return new DOMRect(); 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(); this.render();
} }
@ -279,22 +288,15 @@ export class FormattingMenu {
const isStyleEnabledInSelection = this.isStyleEnabledInSelection.bind(this); const isStyleEnabledInSelection = this.isStyleEnabledInSelection.bind(this);
const toggleForStyle = this.toggleForStyle.bind(this); const toggleForStyle = this.toggleForStyle.bind(this);
const element = createPortal( const element = createPortal(
<Popper <Popper placement="top" referenceElement={this.referenceElement}>
placement="top"
referenceElement={this.referenceElement}
modifiers={[
{
name: 'fadeIn',
enabled: true,
phase: 'write',
fn({ state }) {
// eslint-disable-next-line no-param-reassign
state.elements.popper.style.opacity = '1';
},
},
]}
>
{({ ref, style }) => { {({ ref, style }) => {
const opacity =
style.transform &&
!this.options.isMouseDown &&
!this.fadingOutTimeout
? 1
: 0;
const [hasLongHovered, setHasLongHovered] = const [hasLongHovered, setHasLongHovered] =
React.useState<boolean>(false); React.useState<boolean>(false);
const onLongHover = React.useCallback( const onLongHover = React.useCallback(
@ -308,14 +310,14 @@ export class FormattingMenu {
<div <div
ref={ref} ref={ref}
className="module-composition-input__format-menu" className="module-composition-input__format-menu"
style={style} style={{ ...style, opacity }}
role="menu" role="menu"
tabIndex={0} tabIndex={0}
onMouseLeave={() => setHasLongHovered(false)} onMouseLeave={() => setHasLongHovered(false)}
> >
<FormattingButton <FormattingButton
hasLongHovered={hasLongHovered} hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection} isActive={isStyleEnabledInSelection(QuillFormattingStyle.bold)}
label={i18n('icu:Keyboard--composer--bold')} label={i18n('icu:Keyboard--composer--bold')}
onLongHover={onLongHover} onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${BOLD_CHAR}`} popupGuideShortcut={`${metaKey} + ${BOLD_CHAR}`}
@ -325,7 +327,9 @@ export class FormattingMenu {
/> />
<FormattingButton <FormattingButton
hasLongHovered={hasLongHovered} hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection} isActive={isStyleEnabledInSelection(
QuillFormattingStyle.italic
)}
label={i18n('icu:Keyboard--composer--italic')} label={i18n('icu:Keyboard--composer--italic')}
onLongHover={onLongHover} onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${ITALIC_CHAR}`} popupGuideShortcut={`${metaKey} + ${ITALIC_CHAR}`}
@ -335,7 +339,9 @@ export class FormattingMenu {
/> />
<FormattingButton <FormattingButton
hasLongHovered={hasLongHovered} hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection} isActive={isStyleEnabledInSelection(
QuillFormattingStyle.strike
)}
label={i18n('icu:Keyboard--composer--strikethrough')} label={i18n('icu:Keyboard--composer--strikethrough')}
onLongHover={onLongHover} onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${STRIKETHROUGH_CHAR}`} popupGuideShortcut={`${metaKey} + ${shiftKey} + ${STRIKETHROUGH_CHAR}`}
@ -345,7 +351,9 @@ export class FormattingMenu {
/> />
<FormattingButton <FormattingButton
hasLongHovered={hasLongHovered} hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection} isActive={isStyleEnabledInSelection(
QuillFormattingStyle.monospace
)}
label={i18n('icu:Keyboard--composer--monospace')} label={i18n('icu:Keyboard--composer--monospace')}
onLongHover={onLongHover} onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${MONOSPACE_CHAR}`} popupGuideShortcut={`${metaKey} + ${MONOSPACE_CHAR}`}
@ -356,7 +364,9 @@ export class FormattingMenu {
{isSpoilersEnabled ? ( {isSpoilersEnabled ? (
<FormattingButton <FormattingButton
hasLongHovered={hasLongHovered} hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection} isActive={isStyleEnabledInSelection(
QuillFormattingStyle.spoiler
)}
onLongHover={onLongHover} onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${SPOILER_CHAR}`} popupGuideShortcut={`${metaKey} + ${shiftKey} + ${SPOILER_CHAR}`}
popupGuideText={i18n('icu:FormatMenu--guide--spoiler')} popupGuideText={i18n('icu:FormatMenu--guide--spoiler')}
@ -390,7 +400,7 @@ export class FormattingMenu {
function FormattingButton({ function FormattingButton({
hasLongHovered, hasLongHovered,
isStyleEnabledInSelection, isActive,
label, label,
onLongHover, onLongHover,
popupGuideText, popupGuideText,
@ -399,9 +409,7 @@ function FormattingButton({
toggleForStyle, toggleForStyle,
}: { }: {
hasLongHovered: boolean; hasLongHovered: boolean;
isStyleEnabledInSelection: ( isActive: boolean | undefined;
style: QuillFormattingStyle
) => boolean | undefined;
label: string; label: string;
onLongHover: (value: boolean) => unknown; onLongHover: (value: boolean) => unknown;
popupGuideText: string; popupGuideText: string;
@ -413,6 +421,15 @@ function FormattingButton({
const timerRef = React.useRef<NodeJS.Timeout | undefined>(); const timerRef = React.useRef<NodeJS.Timeout | undefined>();
const [isHovered, setIsHovered] = React.useState<boolean>(false); const [isHovered, setIsHovered] = React.useState<boolean>(false);
React.useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
};
}, []);
return ( return (
<> <>
{hasLongHovered && isHovered && buttonRef.current ? ( {hasLongHovered && isHovered && buttonRef.current ? (
@ -434,7 +451,12 @@ function FormattingButton({
<button <button
ref={buttonRef} ref={buttonRef}
type="button" type="button"
className="module-composition-input__format-menu__item" className={classNames(
'module-composition-input__format-menu__item',
isActive
? 'module-composition-input__format-menu__item--active'
: null
)}
aria-label={label} aria-label={label}
onClick={event => { onClick={event => {
event.preventDefault(); event.preventDefault();
@ -467,7 +489,7 @@ function FormattingButton({
className={classNames( className={classNames(
'module-composition-input__format-menu__item__icon', 'module-composition-input__format-menu__item__icon',
`module-composition-input__format-menu__item__icon--${style}`, `module-composition-input__format-menu__item__icon--${style}`,
isStyleEnabledInSelection(style) isActive
? 'module-composition-input__format-menu__item__icon--active' ? 'module-composition-input__format-menu__item__icon--active'
: null : null
)} )}

View file

@ -30,7 +30,6 @@ export class SignalClipboard {
clipboard.matchers = clipboard.matchers.slice(11); clipboard.matchers = clipboard.matchers.slice(11);
} }
// TODO: do we need this anymore, given that we aren't using signal/html?
onCapturePaste(event: ClipboardEvent): void { onCapturePaste(event: ClipboardEvent): void {
if (event.clipboardData == null) { if (event.clipboardData == null) {
return; return;

View file

@ -14,6 +14,7 @@ import type {
import { BodyRange } from '../types/BodyRange'; import { BodyRange } from '../types/BodyRange';
import type { MentionBlot } from './mentions/blot'; import type { MentionBlot } from './mentions/blot';
import { QuillFormattingStyle } from './formatting/menu'; import { QuillFormattingStyle } from './formatting/menu';
import { isNotNil } from '../util/isNotNil';
export type MentionBlotValue = { export type MentionBlotValue = {
uuid: string; uuid: string;
@ -153,7 +154,7 @@ function extractAllFormats(
export const getTextAndRangesFromOps = ( export const getTextAndRangesFromOps = (
ops: Array<Op> ops: Array<Op>
): { text: string; bodyRanges: DraftBodyRanges } => { ): { text: string; bodyRanges: DraftBodyRanges } => {
const bodyRanges: Array<DraftBodyRange> = []; const startingBodyRanges: Array<DraftBodyRange> = [];
let formats: Record<BodyRange.Style, { start: number } | undefined> = { let formats: Record<BodyRange.Style, { start: number } | undefined> = {
[BOLD]: undefined, [BOLD]: undefined,
[ITALIC]: undefined, [ITALIC]: undefined,
@ -163,37 +164,78 @@ export const getTextAndRangesFromOps = (
[NONE]: undefined, [NONE]: undefined,
}; };
const text = ops const preTrimText = ops.reduce((acc, op) => {
.reduce((acc, op, index) => { // Start or finish format sections as needed
// Start or finish format sections as needed formats = extractAllFormats(startingBodyRanges, formats, acc.length, op);
formats = extractAllFormats(bodyRanges, formats, acc.length, op);
if (typeof op.insert === 'string') { if (typeof op.insert === 'string') {
const toAdd = index === 0 ? op.insert.trimStart() : op.insert; return acc + op.insert;
return acc + toAdd; }
}
if (isInsertEmojiOp(op)) { if (isInsertEmojiOp(op)) {
return acc + op.insert.emoji; return acc + op.insert.emoji;
} }
if (isInsertMentionOp(op)) { if (isInsertMentionOp(op)) {
bodyRanges.push({ startingBodyRanges.push({
length: 1, // The length of `\uFFFC` length: 1, // The length of `\uFFFC`
mentionUuid: op.insert.mention.uuid, mentionUuid: op.insert.mention.uuid,
replacementText: op.insert.mention.title, replacementText: op.insert.mention.title,
start: acc.length, start: acc.length,
}); });
return `${acc}\uFFFC`; return `${acc}\uFFFC`;
} }
return acc; return acc;
}, '') }, '');
.trimEnd(); // Trimming the start of this string will mess up mention indices
// Close off any pending formats // Close off any pending formats
extractAllFormats(bodyRanges, formats, text.length); extractAllFormats(startingBodyRanges, formats, preTrimText.length);
// Now repair bodyRanges after trimming
const trimStart = preTrimText.trimStart();
const trimmedFromStart = preTrimText.length - trimStart.length;
const text = trimStart.trimEnd();
const textLength = text.length;
const bodyRanges = startingBodyRanges
.map(startingRange => {
let range = {
...startingRange,
start: startingRange.start - trimmedFromStart,
};
if (range.start >= text.length) {
return null;
}
const underStartBy = -range.start;
if (underStartBy > 0) {
const length = range.length - underStartBy;
if (length <= 0) {
return null;
}
range = {
...range,
start: 0,
length,
};
}
const end = range.start + range.length;
const overEndBy = end - textLength;
if (overEndBy > 0) {
range = {
...range,
length: range.length - overEndBy,
};
}
return range;
})
.filter(isNotNil);
return { text, bodyRanges }; return { text, bodyRanges };
}; };

View file

@ -120,6 +120,12 @@ export const getConversationsByGroupId = createSelector(
return state.conversationsByGroupId; return state.conversationsByGroupId;
} }
); );
export const getTargetedConversationsPanelsCount = createSelector(
getConversations,
(state: ConversationsStateType): number => {
return state.targetedConversationPanels.length;
}
);
export const getConversationsByUsername = createSelector( export const getConversationsByUsername = createSelector(
getConversations, getConversations,
(state: ConversationsStateType): ConversationLookupType => { (state: ConversationsStateType): ConversationLookupType => {

View file

@ -26,6 +26,7 @@ import {
getConversationSelector, getConversationSelector,
getGroupAdminsSelector, getGroupAdminsSelector,
getSelectedMessageIds, getSelectedMessageIds,
getTargetedConversationsPanelsCount,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getPropsForQuote } from '../selectors/message'; import { getPropsForQuote } from '../selectors/message';
@ -59,6 +60,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props; const { id } = props;
const platform = getPlatform(state); const platform = getPlatform(state);
const shouldHidePopovers = getTargetedConversationsPanelsCount(state) > 0;
const conversationSelector = getConversationSelector(state); const conversationSelector = getConversationSelector(state);
const conversation = conversationSelector(id); const conversation = conversationSelector(id);
if (!conversation) { if (!conversation) {
@ -137,6 +140,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
messageCompositionId, messageCompositionId,
platform, platform,
sendCounter, sendCounter,
shouldHidePopovers,
theme: getTheme(state), theme: getTheme(state),
// AudioCapture // AudioCapture

View file

@ -102,6 +102,29 @@ describe('getTextAndRangesFromOps', () => {
}); });
describe('given formatting', () => { describe('given formatting', () => {
it('handles trimming with simple ops', () => {
const ops = [
{
insert: 'test test ',
attributes: { bold: true },
},
// This is something Quill does for some reason
{
insert: '\n',
},
];
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, 'test test');
assert.equal(bodyRanges.length, 1);
assert.deepEqual(bodyRanges, [
{
start: 0,
length: 9,
style: BodyRange.Style.BOLD,
},
]);
});
it('handles trimming at the end of the message', () => { it('handles trimming at the end of the message', () => {
const ops = [ const ops = [
{ {
@ -157,6 +180,52 @@ describe('getTextAndRangesFromOps', () => {
}, },
]); ]);
}); });
it('handles formatting of whitespace at beginning/ending of message', () => {
const ops = [
{
insert: ' ',
attributes: { bold: true, italic: true, strike: true },
},
{
insert: ' ',
attributes: { italic: true, strike: true, spoiler: true },
},
{
insert: 'so much whitespace',
attributes: { strike: true, spoiler: true, monospace: true },
},
{
insert: ' ',
attributes: { spoiler: true, monospace: true, italic: true },
},
{
insert: ' ',
attributes: { monospace: true, italic: true, bold: true },
},
{ insert: '\n' },
];
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, 'so much whitespace');
assert.equal(bodyRanges.length, 3);
assert.deepEqual(bodyRanges, [
{
start: 0,
length: 18,
style: BodyRange.Style.STRIKETHROUGH,
},
{
start: 0,
length: 18,
style: BodyRange.Style.SPOILER,
},
{
start: 0,
length: 18,
style: BodyRange.Style.MONOSPACE,
},
]);
});
}); });
describe('given text, emoji, and mentions', () => { describe('given text, emoji, and mentions', () => {