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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|