Formatting: A few more changes
1
images/icons/v3/text_format/textformat-italic-bold.svg
Normal 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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
1
images/icons/v3/text_format/textformat-spoiler-bold.svg
Normal 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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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<HTMLElement>) => 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<boolean>(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 (
|
||||
<Manager>
|
||||
<Reference>
|
||||
|
@ -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 && (
|
||||
<div className={getClassName('__editing-message')}>
|
||||
|
@ -866,9 +930,13 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
)}
|
||||
>
|
||||
{reactQuill}
|
||||
{emojiCompletionElement}
|
||||
{formattingChooserElement}
|
||||
{mentionCompletionElement}
|
||||
{shouldHidePopovers ? null : (
|
||||
<>
|
||||
{emojiCompletionElement}
|
||||
{mentionCompletionElement}
|
||||
{formattingChooserElement}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
// <FormattingButton /> 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(
|
||||
<Popper
|
||||
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';
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Popper placement="top" referenceElement={this.referenceElement}>
|
||||
{({ ref, style }) => {
|
||||
const opacity =
|
||||
style.transform &&
|
||||
!this.options.isMouseDown &&
|
||||
!this.fadingOutTimeout
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const [hasLongHovered, setHasLongHovered] =
|
||||
React.useState<boolean>(false);
|
||||
const onLongHover = React.useCallback(
|
||||
|
@ -308,14 +310,14 @@ export class FormattingMenu {
|
|||
<div
|
||||
ref={ref}
|
||||
className="module-composition-input__format-menu"
|
||||
style={style}
|
||||
style={{ ...style, opacity }}
|
||||
role="menu"
|
||||
tabIndex={0}
|
||||
onMouseLeave={() => setHasLongHovered(false)}
|
||||
>
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
isActive={isStyleEnabledInSelection(QuillFormattingStyle.bold)}
|
||||
label={i18n('icu:Keyboard--composer--bold')}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${BOLD_CHAR}`}
|
||||
|
@ -325,7 +327,9 @@ export class FormattingMenu {
|
|||
/>
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
isActive={isStyleEnabledInSelection(
|
||||
QuillFormattingStyle.italic
|
||||
)}
|
||||
label={i18n('icu:Keyboard--composer--italic')}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${ITALIC_CHAR}`}
|
||||
|
@ -335,7 +339,9 @@ export class FormattingMenu {
|
|||
/>
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
isActive={isStyleEnabledInSelection(
|
||||
QuillFormattingStyle.strike
|
||||
)}
|
||||
label={i18n('icu:Keyboard--composer--strikethrough')}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${STRIKETHROUGH_CHAR}`}
|
||||
|
@ -345,7 +351,9 @@ export class FormattingMenu {
|
|||
/>
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
isActive={isStyleEnabledInSelection(
|
||||
QuillFormattingStyle.monospace
|
||||
)}
|
||||
label={i18n('icu:Keyboard--composer--monospace')}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${MONOSPACE_CHAR}`}
|
||||
|
@ -356,7 +364,9 @@ export class FormattingMenu {
|
|||
{isSpoilersEnabled ? (
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
isActive={isStyleEnabledInSelection(
|
||||
QuillFormattingStyle.spoiler
|
||||
)}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${SPOILER_CHAR}`}
|
||||
popupGuideText={i18n('icu:FormatMenu--guide--spoiler')}
|
||||
|
@ -390,7 +400,7 @@ export class FormattingMenu {
|
|||
|
||||
function FormattingButton({
|
||||
hasLongHovered,
|
||||
isStyleEnabledInSelection,
|
||||
isActive,
|
||||
label,
|
||||
onLongHover,
|
||||
popupGuideText,
|
||||
|
@ -399,9 +409,7 @@ function FormattingButton({
|
|||
toggleForStyle,
|
||||
}: {
|
||||
hasLongHovered: boolean;
|
||||
isStyleEnabledInSelection: (
|
||||
style: QuillFormattingStyle
|
||||
) => boolean | undefined;
|
||||
isActive: boolean | undefined;
|
||||
label: string;
|
||||
onLongHover: (value: boolean) => unknown;
|
||||
popupGuideText: string;
|
||||
|
@ -413,6 +421,15 @@ function FormattingButton({
|
|||
const timerRef = React.useRef<NodeJS.Timeout | undefined>();
|
||||
const [isHovered, setIsHovered] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasLongHovered && isHovered && buttonRef.current ? (
|
||||
|
@ -434,7 +451,12 @@ function FormattingButton({
|
|||
<button
|
||||
ref={buttonRef}
|
||||
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}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
|
@ -467,7 +489,7 @@ function FormattingButton({
|
|||
className={classNames(
|
||||
'module-composition-input__format-menu__item__icon',
|
||||
`module-composition-input__format-menu__item__icon--${style}`,
|
||||
isStyleEnabledInSelection(style)
|
||||
isActive
|
||||
? 'module-composition-input__format-menu__item__icon--active'
|
||||
: null
|
||||
)}
|
||||
|
|
|
@ -30,7 +30,6 @@ export class SignalClipboard {
|
|||
clipboard.matchers = clipboard.matchers.slice(11);
|
||||
}
|
||||
|
||||
// TODO: do we need this anymore, given that we aren't using signal/html?
|
||||
onCapturePaste(event: ClipboardEvent): void {
|
||||
if (event.clipboardData == null) {
|
||||
return;
|
||||
|
|
|
@ -14,6 +14,7 @@ import type {
|
|||
import { BodyRange } from '../types/BodyRange';
|
||||
import type { MentionBlot } from './mentions/blot';
|
||||
import { QuillFormattingStyle } from './formatting/menu';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
|
||||
export type MentionBlotValue = {
|
||||
uuid: string;
|
||||
|
@ -153,7 +154,7 @@ function extractAllFormats(
|
|||
export const getTextAndRangesFromOps = (
|
||||
ops: Array<Op>
|
||||
): { text: string; bodyRanges: DraftBodyRanges } => {
|
||||
const bodyRanges: Array<DraftBodyRange> = [];
|
||||
const startingBodyRanges: Array<DraftBodyRange> = [];
|
||||
let formats: Record<BodyRange.Style, { start: number } | undefined> = {
|
||||
[BOLD]: undefined,
|
||||
[ITALIC]: undefined,
|
||||
|
@ -163,37 +164,78 @@ export const getTextAndRangesFromOps = (
|
|||
[NONE]: undefined,
|
||||
};
|
||||
|
||||
const text = ops
|
||||
.reduce((acc, op, index) => {
|
||||
// Start or finish format sections as needed
|
||||
formats = extractAllFormats(bodyRanges, formats, acc.length, op);
|
||||
const preTrimText = ops.reduce((acc, op) => {
|
||||
// Start or finish format sections as needed
|
||||
formats = extractAllFormats(startingBodyRanges, formats, acc.length, op);
|
||||
|
||||
if (typeof op.insert === 'string') {
|
||||
const toAdd = index === 0 ? op.insert.trimStart() : op.insert;
|
||||
return acc + toAdd;
|
||||
}
|
||||
if (typeof op.insert === 'string') {
|
||||
return acc + op.insert;
|
||||
}
|
||||
|
||||
if (isInsertEmojiOp(op)) {
|
||||
return acc + op.insert.emoji;
|
||||
}
|
||||
if (isInsertEmojiOp(op)) {
|
||||
return acc + op.insert.emoji;
|
||||
}
|
||||
|
||||
if (isInsertMentionOp(op)) {
|
||||
bodyRanges.push({
|
||||
length: 1, // The length of `\uFFFC`
|
||||
mentionUuid: op.insert.mention.uuid,
|
||||
replacementText: op.insert.mention.title,
|
||||
start: acc.length,
|
||||
});
|
||||
if (isInsertMentionOp(op)) {
|
||||
startingBodyRanges.push({
|
||||
length: 1, // The length of `\uFFFC`
|
||||
mentionUuid: op.insert.mention.uuid,
|
||||
replacementText: op.insert.mention.title,
|
||||
start: acc.length,
|
||||
});
|
||||
|
||||
return `${acc}\uFFFC`;
|
||||
}
|
||||
return `${acc}\uFFFC`;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, '')
|
||||
.trimEnd(); // Trimming the start of this string will mess up mention indices
|
||||
return acc;
|
||||
}, '');
|
||||
|
||||
// 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 };
|
||||
};
|
||||
|
|
|
@ -120,6 +120,12 @@ export const getConversationsByGroupId = createSelector(
|
|||
return state.conversationsByGroupId;
|
||||
}
|
||||
);
|
||||
export const getTargetedConversationsPanelsCount = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): number => {
|
||||
return state.targetedConversationPanels.length;
|
||||
}
|
||||
);
|
||||
export const getConversationsByUsername = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): ConversationLookupType => {
|
||||
|
|
|
@ -26,6 +26,7 @@ import {
|
|||
getConversationSelector,
|
||||
getGroupAdminsSelector,
|
||||
getSelectedMessageIds,
|
||||
getTargetedConversationsPanelsCount,
|
||||
isMissingRequiredProfileSharing,
|
||||
} from '../selectors/conversations';
|
||||
import { getPropsForQuote } from '../selectors/message';
|
||||
|
@ -59,6 +60,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
const { id } = props;
|
||||
const platform = getPlatform(state);
|
||||
|
||||
const shouldHidePopovers = getTargetedConversationsPanelsCount(state) > 0;
|
||||
|
||||
const conversationSelector = getConversationSelector(state);
|
||||
const conversation = conversationSelector(id);
|
||||
if (!conversation) {
|
||||
|
@ -137,6 +140,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
messageCompositionId,
|
||||
platform,
|
||||
sendCounter,
|
||||
shouldHidePopovers,
|
||||
theme: getTheme(state),
|
||||
|
||||
// AudioCapture
|
||||
|
|
|
@ -102,6 +102,29 @@ describe('getTextAndRangesFromOps', () => {
|
|||
});
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
|
|