Replace Draft with Quill for composition area

Co-authored-by: Sidney Keese <sidney@carbonfive.com>
This commit is contained in:
Sidney Keese 2020-10-21 09:53:32 -07:00 committed by Evan Hahn
parent 544995cc21
commit fbf93374c1
20 changed files with 2933 additions and 1130 deletions

View file

@ -494,40 +494,6 @@ Signal Desktop makes use of the following open source projects.
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## draft-js
BSD License
For Draft.js software
Copyright (c) 2013-present, Facebook, Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook nor the names of its contributors may be used to
endorse or promote products derived from this software without specific
prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## emoji-datasource
The MIT License (MIT)
@ -1856,6 +1822,38 @@ Signal Desktop makes use of the following open source projects.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## parchment
Copyright (c) 2015, Jason Chen
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## pify
MIT License
@ -1918,6 +1916,63 @@ Signal Desktop makes use of the following open source projects.
License: MIT
## quill
Copyright (c) 2014, Jason Chen
Copyright (c) 2013, salesforce.com
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## quill-delta
The MIT License (MIT)
Copyright (c) 2014 Jason Chen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## react
MIT License
@ -2070,6 +2125,30 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## react-quill
The MIT License (MIT)
Copyright (c) 2020, zenoamaro <zenoamaro@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## react-redux
License: MIT

View file

@ -21,8 +21,8 @@
>
<title>Signal</title>
<link href="node_modules/sanitize.css/sanitize.css" rel="stylesheet" type="text/css" />
<link href="node_modules/react-quill/dist/quill.core.css" rel="stylesheet" type="text/css" />
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<link href="node_modules/draft-js/dist/Draft.css" rel="stylesheet" type="text/css" />
<!--
When making changes to these templates, be sure to update test/index.html as well

View file

@ -76,7 +76,6 @@
"copy-text-to-clipboard": "2.1.0",
"curve25519-n": "https://github.com/scottnonnenberg-signal/node-curve25519.git#3e94f60bc54b2426476520d8d1a0aa835c25f5cc",
"dashdash": "1.14.1",
"draft-js": "0.10.5",
"emoji-datasource": "5.0.1",
"emoji-datasource-apple": "5.0.1",
"emoji-regex": "8.0.0",
@ -107,10 +106,13 @@
"p-map": "2.1.0",
"p-props": "4.0.0",
"p-queue": "6.2.1",
"parchment": "1.1.4",
"pify": "3.0.0",
"popper.js": "1.15.0",
"protobufjs": "6.8.6",
"proxy-agent": "3.1.1",
"quill": "1.3.7",
"quill-delta": "4.0.1",
"react": "16.8.3",
"react-blurhash": "0.1.2",
"react-contextmenu": "2.11.0",
@ -119,6 +121,7 @@
"react-hot-loader": "4.12.11",
"react-measure": "2.3.0",
"react-popper": "1.3.7",
"react-quill": "2.0.0-beta.2",
"react-redux": "7.1.0",
"react-router-dom": "5.0.1",
"react-sortable-hoc": "1.9.1",
@ -162,7 +165,6 @@
"@types/classnames": "2.2.3",
"@types/config": "0.0.34",
"@types/dashdash": "1.14.0",
"@types/draft-js": "0.10.32",
"@types/filesize": "3.6.0",
"@types/fs-extra": "5.0.5",
"@types/google-libphonenumber": "7.4.14",
@ -181,6 +183,7 @@
"@types/node-forge": "0.9.5",
"@types/normalize-path": "3.0.0",
"@types/pify": "3.0.2",
"@types/quill": "1.3.10",
"@types/react": "16.8.5",
"@types/react-dom": "16.8.2",
"@types/react-measure": "2.0.5",

View file

@ -8652,20 +8652,32 @@ button.module-image__border-overlay:focus {
// Module: CompositionInput
.module-composition-input {
&__input {
@include font-body-1;
&__quill {
height: 100%;
.ql-editor {
padding: 0;
&.ql-blank::before {
left: 0;
right: 0;
font-style: normal;
}
}
}
&__input {
border: none;
border-radius: 18px;
overflow: hidden;
word-break: break-word;
&:placeholder {
color: $color-gray-45;
// Override Quill styles
.ql-container {
@include font-body-1;
}
// Override draft.js styles
.public-DraftEditorPlaceholder-root {
.ql-blank::before {
color: $color-gray-45;
}
@ -8745,6 +8757,10 @@ button.module-image__border-overlay:focus {
border: none;
width: 100%;
&:focus {
outline: 0;
}
@include font-body-2;
@include light-theme() {

View file

@ -1,6 +1,5 @@
import * as React from 'react';
import 'draft-js/dist/Draft.css';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
@ -32,7 +31,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
onChooseAttachment: action('onChooseAttachment'),
// CompositionInput
onSubmit: action('onSubmit'),
onEditorSizeChange: action('onEditorSizeChange'),
onEditorStateChange: action('onEditorStateChange'),
onTextTooLong: action('onTextTooLong'),
startingText: overrideProps.startingText || undefined,

View file

@ -1,5 +1,4 @@
import * as React from 'react';
import { Editor } from 'draft-js';
import { get, noop } from 'lodash';
import classNames from 'classnames';
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
@ -47,7 +46,6 @@ export type OwnProps = {
export type Props = Pick<
CompositionInputProps,
| 'onSubmit'
| 'onEditorSizeChange'
| 'onEditorStateChange'
| 'onTextTooLong'
| 'startingText'
@ -90,7 +88,6 @@ export const CompositionArea = ({
// CompositionInput
onSubmit,
compositionApi,
onEditorSizeChange,
onEditorStateChange,
onTextTooLong,
startingText,
@ -137,7 +134,6 @@ export const CompositionArea = ({
const [micActive, setMicActive] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
const [large, setLarge] = React.useState(false);
const editorRef = React.useRef<Editor>(null);
const inputApiRef = React.useRef<InputApi | undefined>();
const handleForceSend = React.useCallback(() => {
@ -156,10 +152,10 @@ export const CompositionArea = ({
);
const focusInput = React.useCallback(() => {
if (editorRef.current) {
editorRef.current.focus();
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, [editorRef]);
}, [inputApiRef]);
const withStickers =
countStickers({
@ -413,11 +409,9 @@ export const CompositionArea = ({
i18n={i18n}
disabled={disabled}
large={large}
editorRef={editorRef}
inputApi={inputApiRef}
onPickEmoji={onPickEmoji}
onSubmit={handleSubmit}
onEditorSizeChange={onEditorSizeChange}
onEditorStateChange={onEditorStateChange}
onTextTooLong={onTextTooLong}
onDirtyChange={setDirty}

View file

@ -1,6 +1,6 @@
import * as React from 'react';
import 'draft-js/dist/Draft.css';
import 'react-quill/dist/quill.core.css';
import { boolean, select } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
@ -17,7 +17,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
disabled: boolean('disabled', overrideProps.disabled || false),
onSubmit: action('onSubmit'),
onEditorSizeChange: action('onEditorSizeChange'),
onEditorStateChange: action('onEditorStateChange'),
onTextTooLong: action('onTextTooLong'),
startingText: overrideProps.startingText || undefined,

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
import * as React from 'react';
import 'draft-js/dist/Draft.css';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean, date, select, text } from '@storybook/addon-knobs';

View file

@ -56,7 +56,7 @@ export const Emoji = React.memo(
style={{ ...style, ...backgroundStyle }}
>
{inline ? (
// When using this component as a draft.js decorator it is very
// When using this component as in a CompositionInput it is very
// important that these children are the only elements to render
children
) : (

View file

@ -20,7 +20,10 @@ import { dataByCategory, search } from './lib';
import { useRestoreFocus } from '../../util/hooks';
import { LocalizerType } from '../../types/Util';
export type EmojiPickDataType = { skinTone?: number; shortName: string };
export type EmojiPickDataType = {
skinTone?: number;
shortName: string;
};
export type OwnProps = {
readonly i18n: LocalizerType;

View file

@ -240,14 +240,14 @@ export function unifiedToEmoji(unified: string): string {
.join('');
}
export function convertShortName(
export function convertShortNameToData(
shortName: string,
skinTone: number | SkinToneKey = 0
): string {
): EmojiData | undefined {
const base = dataByShortName[shortName];
if (!base) {
return '';
return undefined;
}
const toneKey = is.number(skinTone) ? skinTones[skinTone - 1] : skinTone;
@ -255,11 +255,27 @@ export function convertShortName(
if (skinTone && base.skin_variations) {
const variation = base.skin_variations[toneKey];
if (variation) {
return unifiedToEmoji(variation.unified);
return {
...base,
...variation,
};
}
}
return unifiedToEmoji(base.unified);
return base;
}
export function convertShortName(
shortName: string,
skinTone: number | SkinToneKey = 0
): string {
const emojiData = convertShortNameToData(shortName, skinTone);
if (!emojiData) {
return '';
}
return unifiedToEmoji(emojiData.unified);
}
export function emojiToImage(emoji: string): string | undefined {

36
ts/quill/emoji/blot.tsx Normal file
View file

@ -0,0 +1,36 @@
import React from 'react';
import Parchment from 'parchment';
import Quill from 'quill';
import { render } from 'react-dom';
import { Emoji } from '../../components/emoji/Emoji';
const Embed: typeof Parchment.Embed = Quill.import('blots/embed');
export class EmojiBlot extends Embed {
static blotName = 'emoji';
static tagName = 'span';
static className = 'emoji-blot';
static create(emoji: string): Node {
const node = super.create(undefined) as HTMLElement;
node.dataset.emoji = emoji;
const emojiSpan = document.createElement('span');
render(
<Emoji emoji={emoji} inline size={20}>
{emoji}
</Emoji>,
emojiSpan
);
node.appendChild(emojiSpan);
return node;
}
static value(node: HTMLElement): string | undefined {
return node.dataset.emoji;
}
}

View file

@ -0,0 +1,340 @@
import Quill from 'quill';
import Delta from 'quill-delta';
import React from 'react';
import { Popper } from 'react-popper';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import {
EmojiData,
search,
convertShortName,
isShortName,
convertShortNameToData,
} from '../../components/emoji/lib';
import { Emoji } from '../../components/emoji/Emoji';
import { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
type UpdatedDelta = Delta;
declare module 'quill' {
// this type is fixed in @types/quill, but our version of react-quill cannot
// use the version of quill that has this fix in its typings
// doing this manually allows us to use the correct type
// https://github.com/DefinitelyTyped/DefinitelyTyped/commit/6090a81c7dbd02b6b917f903a28c6c010b8432ea#diff-bff5e435d15f8f99f733c837e76945bced86bb85e93a75467015cc9b33b48212
interface UpdatedKey {
key: string | number;
shiftKey?: boolean;
}
interface Blot {
text?: string;
}
interface Quill {
updateContents(delta: UpdatedDelta, source?: Sources): UpdatedDelta;
getLeaf(index: number): [Blot, number];
}
interface KeyboardStatic {
addBinding(
key: UpdatedKey,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callback: (range: RangeStatic, context: any) => void
): void;
}
}
interface EmojiPickerOptions {
onPickEmoji: (emoji: EmojiPickDataType) => void;
setEmojiPickerElement: (element: JSX.Element | null) => void;
skinTone: number;
}
export class EmojiCompletion {
results: Array<EmojiData>;
index: number;
options: EmojiPickerOptions;
root: HTMLDivElement;
quill: Quill;
constructor(quill: Quill, options: EmojiPickerOptions) {
this.results = [];
this.index = 0;
this.options = options;
this.root = document.body.appendChild(document.createElement('div'));
this.quill = quill;
const clearResults = () => {
if (this.results.length) {
this.reset();
}
return true;
};
const changeIndex = (by: number) => (): boolean => {
if (this.results.length) {
this.changeIndex(by);
return false;
}
return true;
};
this.quill.keyboard.addBinding({ key: 37 }, clearResults); // 37 = Left
this.quill.keyboard.addBinding({ key: 38 }, changeIndex(-1)); // 38 = Up
this.quill.keyboard.addBinding({ key: 39 }, clearResults); // 39 = Right
this.quill.keyboard.addBinding({ key: 40 }, changeIndex(1)); // 40 = Down
this.quill.on('text-change', this.onTextChange.bind(this));
}
destroy(): void {
this.root.remove();
}
changeIndex(by: number): void {
this.index = (this.index + by + this.results.length) % this.results.length;
this.render();
}
getCurrentLeafTextPartitions(): [string, string] {
const range = this.quill.getSelection();
if (range) {
const [blot, blotIndex] = this.quill.getLeaf(range.index);
if (blot !== undefined && blot.text !== undefined) {
const leftLeafText = blot.text.substr(0, blotIndex);
const rightLeafText = blot.text.substr(blotIndex);
return [leftLeafText, rightLeafText];
}
}
return ['', ''];
}
onTextChange(): void {
const range = this.quill.getSelection();
if (!range) return;
const [leftLeafText, rightLeafText] = this.getCurrentLeafTextPartitions();
const leftTokenTextMatch = /:([-+0-9a-z_]*)(:?)$/.exec(leftLeafText);
const rightTokenTextMatch = /^([-+0-9a-z_]*):/.exec(rightLeafText);
if (!leftTokenTextMatch) {
this.reset();
return;
}
const [, leftTokenText, isSelfClosing] = leftTokenTextMatch;
if (isSelfClosing) {
if (isShortName(leftTokenText)) {
const emojiData = convertShortNameToData(
leftTokenText,
this.options.skinTone
);
if (emojiData) {
this.insertEmoji(
emojiData,
range.index - leftTokenText.length - 2,
leftTokenText.length + 2
);
return;
}
} else {
this.reset();
return;
}
}
if (rightTokenTextMatch) {
const [, rightTokenText] = rightTokenTextMatch;
const tokenText = leftTokenText + rightTokenText;
if (isShortName(tokenText)) {
const emojiData = convertShortNameToData(
tokenText,
this.options.skinTone
);
if (emojiData) {
this.insertEmoji(
emojiData,
range.index - leftTokenText.length - 1,
tokenText.length + 2
);
return;
}
}
}
if (leftTokenText.length < 2) {
this.reset();
return;
}
const results = search(leftTokenText, 10);
if (!results.length) {
this.reset();
return;
}
this.results = results;
this.render();
}
completeEmoji(): void {
const range = this.quill.getSelection();
if (range === null) return;
const emoji = this.results[this.index];
const [leafText] = this.getCurrentLeafTextPartitions();
const tokenTextMatch = /:([-+0-9a-z_]*)(:?)$/.exec(leafText);
if (tokenTextMatch === null) return;
const [, tokenText] = tokenTextMatch;
this.insertEmoji(
emoji,
range.index - tokenText.length - 1,
tokenText.length + 1,
true
);
}
insertEmoji(
emojiData: EmojiData,
index: number,
range: number,
withTrailingSpace = false
): void {
const emoji = convertShortName(emojiData.short_name, this.options.skinTone);
const delta = new Delta()
.retain(index)
.delete(range)
.insert({ emoji });
if (withTrailingSpace) {
this.quill.updateContents(delta.insert(' '), 'user');
this.quill.setSelection(index + 2, 0, 'user');
} else {
this.quill.updateContents(delta, 'user');
this.quill.setSelection(index + 1, 0, 'user');
}
this.options.onPickEmoji({
shortName: emojiData.short_name,
skinTone: this.options.skinTone,
});
this.reset();
}
reset(): void {
if (this.results.length) {
this.results = [];
this.index = 0;
this.render();
}
}
onUnmount(): void {
document.body.removeChild(this.root);
}
render(): void {
const { results: emojiResults, index: emojiResultsIndex } = this;
if (emojiResults.length === 0) {
this.options.setEmojiPickerElement(null);
return;
}
const element = createPortal(
<Popper
placement="top"
modifiers={{
width: {
enabled: true,
fn: oldData => {
const data = oldData;
const { width, left } = data.offsets.reference;
data.styles.width = `${width}px`;
data.offsets.popper.width = width;
data.offsets.popper.left = left;
return data;
},
order: 840,
},
}}
>
{({ ref, style }) => (
<div
ref={ref}
className="module-composition-input__emoji-suggestions"
style={style}
role="listbox"
aria-expanded
aria-activedescendant={`emoji-result--${
emojiResults.length
? emojiResults[emojiResultsIndex].short_name
: ''
}`}
tabIndex={0}
>
{emojiResults.map((emoji, index) => (
<button
type="button"
key={emoji.short_name}
id={`emoji-result--${emoji.short_name}`}
role="option button"
aria-selected={emojiResultsIndex === index}
onClick={() => {
this.index = index;
this.completeEmoji();
}}
className={classNames(
'module-composition-input__emoji-suggestions__row',
emojiResultsIndex === index
? 'module-composition-input__emoji-suggestions__row--selected'
: null
)}
>
<Emoji
shortName={emoji.short_name}
size={16}
skinTone={this.options.skinTone}
/>
<div className="module-composition-input__emoji-suggestions__row__short-name">
:{emoji.short_name}:
</div>
</button>
))}
</div>
)}
</Popper>,
this.root
);
this.options.setEmojiPickerElement(element);
}
}

2
ts/quill/emoji/index.tsx Normal file
View file

@ -0,0 +1,2 @@
export { EmojiBlot } from './blot';
export { EmojiCompletion } from './completion';

21
ts/quill/matchImage.ts Normal file
View file

@ -0,0 +1,21 @@
import Delta from 'quill-delta';
export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => {
if (node.classList.contains('emoji-blot')) {
const { emoji } = node.dataset;
return new Delta().insert({ emoji });
}
if (node.classList.contains('module-emoji')) {
const emoji = node.innerText.trim();
return new Delta().insert({ emoji });
}
return delta;
};
export const matchEmojiImage = (node: Element): Delta => {
if (node.classList.contains('emoji')) {
const emoji = node.getAttribute('title');
return new Delta().insert({ emoji });
}
return new Delta();
};

View file

@ -0,0 +1,356 @@
import { assert } from 'chai';
import sinon from 'sinon';
import { EmojiCompletion } from '../../../quill/emoji/completion';
import { EmojiData } from '../../../components/emoji/lib';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalAsAny = global as any;
describe('emojiCompletion', () => {
let emojiCompletion: EmojiCompletion;
const mockOnPickEmoji = sinon.spy();
const mockSetEmojiPickerElement = sinon.spy();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockQuill: any;
beforeEach(function beforeEach() {
this.oldDocument = globalAsAny.document;
globalAsAny.document = {
body: {
appendChild: () => null,
},
createElement: () => null,
};
mockQuill = {
getLeaf: sinon.stub(),
getSelection: sinon.stub(),
keyboard: {
addBinding: sinon.stub(),
},
on: sinon.stub(),
setSelection: sinon.stub(),
updateContents: sinon.stub(),
};
const options = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPickEmoji: mockOnPickEmoji as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setEmojiPickerElement: mockSetEmojiPickerElement as any,
skinTone: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emojiCompletion = new EmojiCompletion(mockQuill as any, options);
// Stub rendering to avoid missing DOM until we bring in Enzyme
emojiCompletion.render = sinon.stub();
});
afterEach(function afterEach() {
mockOnPickEmoji.resetHistory();
mockSetEmojiPickerElement.resetHistory();
(emojiCompletion.render as sinon.SinonStub).resetHistory();
if (this.oldDocument === undefined) {
delete globalAsAny.document;
} else {
globalAsAny.document = this.oldDocument;
}
});
describe('getCurrentLeafTextPartitions', () => {
it('returns left and right text', () => {
mockQuill.getSelection.returns({ index: 0, length: 0 });
const blot = {
text: ':smile:',
};
mockQuill.getLeaf.returns([blot, 2]);
const [
leftLeafText,
rightLeafText,
] = emojiCompletion.getCurrentLeafTextPartitions();
assert.equal(leftLeafText, ':s');
assert.equal(rightLeafText, 'mile:');
});
});
describe('onTextChange', () => {
let insertEmojiStub: sinon.SinonStub<
[EmojiData, number, number, (boolean | undefined)?],
void
>;
beforeEach(function beforeEach() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emojiCompletion.results = [{ short_name: 'joy' } as any];
emojiCompletion.index = 5;
insertEmojiStub = sinon
.stub(emojiCompletion, 'insertEmoji')
.callThrough();
});
afterEach(function afterEach() {
insertEmojiStub.restore();
});
describe('given an emoji is not starting (no colon)', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 3,
length: 0,
});
const blot = {
text: 'smi',
};
mockQuill.getLeaf.returns([blot, 3]);
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
describe('given an emoji is starting but does not have 2 characters', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 2,
length: 0,
});
const blot = {
text: ':s',
};
mockQuill.getLeaf.returns([blot, 2]);
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
describe('given an emoji is starting but does not match a short name', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 4,
length: 0,
});
const blot = {
text: ':smy',
};
mockQuill.getLeaf.returns([blot, 4]);
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
describe('given an emoji is starting and matches short names', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 4,
length: 0,
});
const blot = {
text: ':smi',
};
mockQuill.getLeaf.returns([blot, 4]);
emojiCompletion.onTextChange();
});
it('stores the results and renders', () => {
assert.equal(emojiCompletion.results.length, 10);
assert.equal((emojiCompletion.render as sinon.SinonStub).called, true);
});
});
describe('given an emoji was just completed', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 7,
length: 0,
});
});
describe('and given it matches a short name', () => {
const text = ':smile:';
beforeEach(function beforeEach() {
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, 7]);
emojiCompletion.onTextChange();
});
it('inserts the emoji at the current cursor position', () => {
const [emoji, index, range] = insertEmojiStub.args[0];
assert.equal(emoji.short_name, 'smile');
assert.equal(index, 0);
assert.equal(range, 7);
});
it('resets the completion', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
describe('and given it does not match a short name', () => {
const text = ':smyle:';
beforeEach(function beforeEach() {
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, 7]);
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
});
describe('given an emoji was just completed from inside the colons', () => {
const validEmoji = ':smile:';
const invalidEmoji = ':smyle:';
const middleCursorIndex = validEmoji.length - 3;
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: middleCursorIndex,
length: 0,
});
});
describe('and given it matches a short name', () => {
beforeEach(function beforeEach() {
const blot = {
text: validEmoji,
};
mockQuill.getLeaf.returns([blot, middleCursorIndex]);
emojiCompletion.onTextChange();
});
it('inserts the emoji at the current cursor position', () => {
const [emoji, index, range] = insertEmojiStub.args[0];
assert.equal(emoji.short_name, 'smile');
assert.equal(index, 0);
assert.equal(range, validEmoji.length);
});
it('resets the completion', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
describe('and given it does not match a short name', () => {
beforeEach(function beforeEach() {
const blot = {
text: invalidEmoji,
};
mockQuill.getLeaf.returns([blot, middleCursorIndex]);
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
});
});
describe('completeEmoji', () => {
let insertEmojiStub: sinon.SinonStub<
[EmojiData, number, number, (boolean | undefined)?],
void
>;
beforeEach(function beforeEach() {
emojiCompletion.results = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ short_name: 'smile' } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ short_name: 'smile_cat' } as any,
];
emojiCompletion.index = 1;
insertEmojiStub = sinon.stub(emojiCompletion, 'insertEmoji');
});
describe('given a valid token', () => {
const text = ':smi';
const index = text.length;
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index,
length: 0,
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
emojiCompletion.completeEmoji();
});
it('inserts the currently selected emoji at the current cursor position', () => {
const [emoji, insertIndex, range] = insertEmojiStub.args[0];
assert.equal(emoji.short_name, 'smile_cat');
assert.equal(insertIndex, 0);
assert.equal(range, text.length);
});
});
describe('given a valid token is not present', () => {
const text = 'smi';
const index = text.length;
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index,
length: 0,
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
emojiCompletion.completeEmoji();
});
it('does not insert anything', () => {
assert.equal(insertEmojiStub.called, false);
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -2210,11 +2210,6 @@ Whisper.ConversationView = Whisper.View.extend({
}
},
focusMessageFieldAndClearDisabled() {
this.compositionApi.current.setDisabled(false);
this.focusMessageField();
},
disableMessageField() {
this.compositionApi.current.setDisabled(true);
},
@ -2928,7 +2923,7 @@ Whisper.ConversationView = Whisper.View.extend({
if (message) {
this.quote = await this.model.makeQuote(this.quotedMessage);
this.focusMessageFieldAndClearDisabled();
this.enableMessageField();
}
}
@ -2993,11 +2988,11 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
this.focusMessageFieldAndClearDisabled();
this.enableMessageField();
return;
}
} catch (error) {
this.focusMessageFieldAndClearDisabled();
this.enableMessageField();
window.log.error(
'sendMessage error:',
error && error.stack ? error.stack : error
@ -3033,7 +3028,7 @@ Whisper.ConversationView = Whisper.View.extend({
if (ToastView) {
this.showToast(ToastView);
this.focusMessageFieldAndClearDisabled();
this.enableMessageField();
return;
}
@ -3070,7 +3065,7 @@ Whisper.ConversationView = Whisper.View.extend({
error && error.stack ? error.stack : error
);
} finally {
this.focusMessageFieldAndClearDisabled();
this.enableMessageField();
}
},

View file

@ -2164,14 +2164,6 @@
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd"
integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==
"@types/draft-js@0.10.32":
version "0.10.32"
resolved "https://registry.yarnpkg.com/@types/draft-js/-/draft-js-0.10.32.tgz#cbfed40c500d9bb1e486813dde73125291e7517d"
integrity sha512-x63qkMUVpK8lAdxYQFk54F92F1mcG202qs4t0I+UqsZyCpkRRJlMQ+1v3QBbPcckVlmmdSg7m2PqoAbeXNjCaA==
dependencies:
"@types/react" "*"
immutable "^3.8.1"
"@types/eslint-visitor-keys@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
@ -2416,6 +2408,13 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==
"@types/quill@1.3.10", "@types/quill@^1.3.10":
version "1.3.10"
resolved "https://registry.yarnpkg.com/@types/quill/-/quill-1.3.10.tgz#dc1f7b6587f7ee94bdf5291bc92289f6f0497613"
integrity sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==
dependencies:
parchment "^1.1.2"
"@types/range-parser@*":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
@ -4916,6 +4915,11 @@ clone-response@1.0.2, clone-response@^1.0.2:
dependencies:
mimic-response "^1.0.0"
clone@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@ -6176,15 +6180,6 @@ dotnet-deps-parser@4.10.0:
tslib "^1.10.0"
xml2js "0.4.23"
draft-js@0.10.5:
version "0.10.5"
resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.10.5.tgz#bfa9beb018fe0533dbb08d6675c371a6b08fa742"
integrity sha512-LE6jSCV9nkPhfVX2ggcRLA4FKs6zWq9ceuO/88BpXdNCS7mjRTgs0NsV6piUCJX9YxMsB9An33wnkMmU2sD2Zg==
dependencies:
fbjs "^0.8.15"
immutable "~3.7.4"
object-assign "^4.1.0"
dtrace-provider@~0.8:
version "0.8.7"
resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.7.tgz#dc939b4d3e0620cfe0c1cd803d0d2d7ed04ffd04"
@ -6967,6 +6962,11 @@ eventemitter2@~0.4.13:
version "0.4.14"
resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-0.4.14.tgz#8f61b75cde012b2e9eb284d4545583b5643b61ab"
eventemitter3@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba"
integrity sha1-teEHm1n7XhuidxwKmTvgYKWMmbo=
eventemitter3@^3.1.0:
version "3.1.2"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
@ -7130,7 +7130,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
assign-symbols "^1.0.0"
is-extendable "^1.0.1"
extend@3, extend@~3.0.0, extend@~3.0.2:
extend@3, extend@^3.0.2, extend@~3.0.0, extend@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
@ -7207,6 +7207,11 @@ fast-deep-equal@^3.1.1:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-diff@1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.1.2.tgz#4b62c42b8e03de3f848460b639079920695d0154"
integrity sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==
fast-diff@^1.1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
@ -7275,7 +7280,7 @@ faye-websocket@~0.11.1:
dependencies:
websocket-driver ">=0.5.1"
fbjs@^0.8.0, fbjs@^0.8.1, fbjs@^0.8.15:
fbjs@^0.8.0, fbjs@^0.8.1:
version "0.8.17"
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd"
integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=
@ -8946,16 +8951,6 @@ immer@^2.0.0:
resolved "https://registry.yarnpkg.com/immer/-/immer-2.1.5.tgz#0389947455c5a2c7a47f1e6f415c9d1a62a1ebed"
integrity sha512-xyjQyTBYIeiz6jd02Hg12jV+9QISwF1crLcwTlzHpWH4e0ryNWj1kacpTwimK3bJV5NKKXw458G2vpqoB/inFA==
immutable@^3.8.1:
version "3.8.2"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=
immutable@~3.7.4:
version "3.7.6"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b"
integrity sha1-E7TTyxK++hVIKib+Gy665kAHHks=
import-cwd@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
@ -12066,6 +12061,11 @@ param-case@2.1.x, param-case@^2.1.1:
dependencies:
no-case "^2.2.0"
parchment@1.1.4, parchment@^1.1.2, parchment@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/parchment/-/parchment-1.1.4.tgz#aeded7ab938fe921d4c34bc339ce1168bc2ffde5"
integrity sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@ -12894,6 +12894,36 @@ querystringify@^2.1.1:
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e"
integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==
quill-delta@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-4.0.1.tgz#21c8f166dffc6cd9a12051f964f7574fc237a167"
integrity sha512-8MdJmDlsnkGTMP3VVqVb/S95l+X691/xY1iQ3tmDDBWkqUyqh72+tBnaEStxfEa5YqzozGeYWz8j2B3pni5Bew==
dependencies:
deep-equal "^1.0.1"
extend "^3.0.2"
fast-diff "1.1.2"
quill-delta@^3.6.2:
version "3.6.3"
resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-3.6.3.tgz#b19fd2b89412301c60e1ff213d8d860eac0f1032"
integrity sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==
dependencies:
deep-equal "^1.0.1"
extend "^3.0.2"
fast-diff "1.1.2"
quill@1.3.7, quill@^1.3.7:
version "1.3.7"
resolved "https://registry.yarnpkg.com/quill/-/quill-1.3.7.tgz#da5b2f3a2c470e932340cdbf3668c9f21f9286e8"
integrity sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==
dependencies:
clone "^2.1.1"
deep-equal "^1.0.1"
eventemitter3 "^2.0.3"
extend "^3.0.2"
parchment "^1.1.4"
quill-delta "^3.6.2"
raf-schd@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0"
@ -13243,6 +13273,15 @@ react-popper@^1.3.3:
typed-styles "^0.0.7"
warning "^4.0.2"
react-quill@2.0.0-beta.2:
version "2.0.0-beta.2"
resolved "https://registry.yarnpkg.com/react-quill/-/react-quill-2.0.0-beta.2.tgz#3aaf7fe9259249d36bcae435e02a4d15bb9980a8"
integrity sha512-jzoq57Mt216sCOr59OC6aMgmckdAh3BgKgEqYI/FcS26JwCMlXMgGu7Dc7TApzObax9dwOWbr6lT8cEWxigyVA==
dependencies:
"@types/quill" "^1.3.10"
lodash "^4.17.4"
quill "^1.3.7"
react-redux@7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.0.tgz#72af7cf490a74acdc516ea9c1dd80e25af9ea0b2"