Improve emoji blot and override clipboard behavior

This commit is contained in:
Sidney Keese 2020-11-06 12:11:18 -08:00 committed by GitHub
parent d4d9688447
commit 91beef7797
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 181 additions and 84 deletions

View File

@ -8692,6 +8692,12 @@ button.module-image__border-overlay:focus {
right: 0;
font-style: normal;
}
.emoji-blot {
width: 20px;
height: 20px;
vertical-align: text-bottom;
}
}
}

View File

@ -31,11 +31,13 @@ import {
isMentionBlot,
getDeltaToRestartMention,
} from '../quill/util';
import { SignalClipboard } from '../quill/signal-clipboard';
Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/mention', MentionBlot);
Quill.register('modules/emojiCompletion', EmojiCompletion);
Quill.register('modules/mentionCompletion', MentionCompletion);
Quill.register('modules/signalClipboard', SignalClipboard);
const Block = Quill.import('blots/block');
Block.tagName = 'DIV';
@ -556,10 +558,11 @@ export const CompositionInput: React.ComponentType<Props> = props => {
defaultValue={delta}
modules={{
toolbar: false,
signalClipboard: true,
clipboard: {
matchers: [
['IMG', matchEmojiImage],
['SPAN', matchEmojiBlot],
['IMG', matchEmojiBlot],
['SPAN', matchReactEmoji],
['SPAN', matchMention(memberRepositoryRef)],
],

View File

@ -11,6 +11,9 @@ import { RenderTextCallbackType } from '../../types/Util';
import { emojiToImage, SizeClassType } from '../emoji/lib';
// Some of this logic taken from emoji-js/replacement
// the DOM structure for this getImageTag should match the other emoji implementations:
// ts/components/emoji/Emoji.tsx
// ts/quill/emoji/blot.tsx
function getImageTag({
match,
sizeClass,

View File

@ -3,7 +3,7 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { boolean, select, text } from '@storybook/addon-knobs';
import { select, text } from '@storybook/addon-knobs';
import { Emoji, EmojiSizes, Props } from './Emoji';
const story = storiesOf('Components/Emoji/Emoji', module);
@ -16,7 +16,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
EmojiSizes.reduce((m, t) => ({ ...m, [t]: t }), {}),
overrideProps.size || 48
),
inline: boolean('inline', overrideProps.inline || false),
emoji: text('emoji', overrideProps.emoji || ''),
shortName: text('shortName', overrideProps.shortName || ''),
skinTone: select(
@ -44,21 +43,6 @@ story.add('Skin Tones', () => {
));
});
story.add('Inline', () => {
const props = createProps({
shortName: 'joy',
inline: true,
});
return (
<>
<Emoji {...props} />
<Emoji {...props} />
<Emoji {...props} />
</>
);
});
story.add('From Emoji', () => {
const props = createProps({
emoji: '😂',

View File

@ -10,7 +10,6 @@ export const EmojiSizes = [16, 18, 20, 24, 28, 32, 48, 64, 66] as const;
export type EmojiSizeType = typeof EmojiSizes[number];
export type OwnProps = {
inline?: boolean;
emoji?: string;
shortName?: string;
skinTone?: SkinToneKey | number;
@ -21,19 +20,14 @@ export type OwnProps = {
export type Props = OwnProps &
Pick<React.HTMLProps<HTMLDivElement>, 'style' | 'className'>;
// the DOM structure of this Emoji should match the other emoji implementations:
// ts/components/conversation/Emojify.tsx
// ts/quill/emoji/blot.tsx
export const Emoji = React.memo(
React.forwardRef<HTMLDivElement, Props>(
(
{
style = {},
size = 28,
shortName,
skinTone,
emoji,
inline,
className,
children,
}: Props,
{ style = {}, size = 28, shortName, skinTone, emoji, className }: Props,
ref
) => {
let image = '';
@ -43,32 +37,22 @@ export const Emoji = React.memo(
image = emojiToImage(emoji) || '';
}
const backgroundStyle = inline
? { backgroundImage: `url('${image}')` }
: {};
return (
<span
ref={ref}
className={classNames(
'module-emoji',
`module-emoji--${size}px`,
inline ? `module-emoji--${size}px--inline` : null,
className
)}
style={{ ...style, ...backgroundStyle }}
style={style}
>
{inline ? (
// When using this component as in a CompositionInput it is very
// important that these children are the only elements to render
children
) : (
<img
className={`module-emoji__image--${size}px`}
src={image}
alt={shortName}
/>
)}
<img
className={`module-emoji__image--${size}px`}
src={image}
aria-label={emoji}
title={emoji}
/>
</span>
);
}

View File

@ -383,7 +383,6 @@ export const EmojiPicker = React.memo(
<Emoji
shortName="slightly_frowning_face"
size={16}
inline
style={{ marginLeft: '4px' }}
/>
</div>

View File

@ -1,19 +1,21 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import Parchment from 'parchment';
import Quill from 'quill';
import { render } from 'react-dom';
import { Emoji } from '../../components/emoji/Emoji';
import { emojiToImage } from '../../components/emoji/lib';
const Embed: typeof Parchment.Embed = Quill.import('blots/embed');
// the DOM structure of this EmojiBlot should match the other emoji implementations:
// ts/components/conversation/Emojify.tsx
// ts/components/emoji/Emoji.tsx
export class EmojiBlot extends Embed {
static blotName = 'emoji';
static tagName = 'span';
static tagName = 'img';
static className = 'emoji-blot';
@ -21,14 +23,12 @@ export class EmojiBlot extends Embed {
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);
const image = emojiToImage(emoji);
node.setAttribute('src', image || '');
node.setAttribute('data-emoji', emoji);
node.setAttribute('title', emoji);
node.setAttribute('aria-label', emoji);
return node;
}

View File

@ -0,0 +1,68 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Quill from 'quill';
import { getTextFromOps } from '../util';
const getSelectionHTML = () => {
const selection = window.getSelection();
if (selection === null) {
return '';
}
const range = selection.getRangeAt(0);
const contents = range.cloneContents();
const div = document.createElement('div');
div.appendChild(contents);
return div.innerHTML;
};
export class SignalClipboard {
quill: Quill;
constructor(quill: Quill) {
this.quill = quill;
this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false));
this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true));
}
onCaptureCopy(event: ClipboardEvent, isCut = false): void {
event.preventDefault();
if (event.clipboardData === null) {
return;
}
const range = this.quill.getSelection();
if (range === null) {
return;
}
const contents = this.quill.getContents(range.index, range.length);
if (contents === null) {
return;
}
const { ops } = contents;
if (ops === undefined) {
return;
}
const text = getTextFromOps(ops);
const html = getSelectionHTML();
event.clipboardData.setData('text/plain', text);
event.clipboardData.setData('text/html', html);
if (isCut) {
this.quill.deleteText(range.index, range.length, 'user');
}
}
}

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import Delta from 'quill-delta';
import { LeafBlot } from 'quill';
import { LeafBlot, DeltaOperation } from 'quill';
import Op from 'quill-delta/dist/Op';
import { BodyRangeType } from '../types/Util';
@ -39,6 +39,38 @@ export const isInsertEmojiOp = (op: Op): op is InsertEmojiOp =>
export const isInsertMentionOp = (op: Op): op is InsertMentionOp =>
isSpecificInsertOp(op, 'mention');
export const getTextFromOps = (ops: Array<DeltaOperation>): string =>
ops.reduce((acc, { insert }, index) => {
if (typeof insert === 'string') {
let textToAdd;
switch (index) {
case 0: {
textToAdd = insert.trimLeft();
break;
}
case ops.length - 1: {
textToAdd = insert.trimRight();
break;
}
default: {
textToAdd = insert;
break;
}
}
return acc + textToAdd;
}
if (insert.emoji) {
return acc + insert.emoji;
}
if (insert.mention) {
return `${acc}@${insert.mention.title}`;
}
return acc;
}, '');
export const getTextAndMentionsFromOps = (
ops: Array<Op>
): [string, Array<BodyRangeType>] => {

View File

@ -14544,24 +14544,6 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const emojiCompletionRef = React.useRef();",
"lineNumber": 43,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const mentionCompletionRef = React.useRef();",
"lineNumber": 44,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T23:54:34.273Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const quillRef = React.useRef();",
"lineNumber": 45,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
@ -14570,16 +14552,16 @@
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const scrollerRef = React.useRef(null);",
"line": " const mentionCompletionRef = React.useRef();",
"lineNumber": 46,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used with Quill for scrolling."
"reasonCategory": "falseMatch",
"updated": "2020-10-26T23:54:34.273Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const propsRef = React.useRef(props);",
"line": " const quillRef = React.useRef();",
"lineNumber": 47,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
@ -14588,8 +14570,26 @@
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
"line": " const scrollerRef = React.useRef(null);",
"lineNumber": 48,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used with Quill for scrolling."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const propsRef = React.useRef(props);",
"lineNumber": 49,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
"lineNumber": 50,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T23:56:13.482Z",
"reasonDetail": "Doesn't refer to a DOM element."
@ -14910,6 +14910,24 @@
"reasonCategory": "usageTrusted",
"updated": "2020-10-30T23:03:08.319Z"
},
{
"rule": "DOM-innerHTML",
"path": "ts/quill/signal-clipboard/index.js",
"line": " return div.innerHTML;",
"lineNumber": 15,
"reasonCategory": "usageTrusted",
"updated": "2020-11-06T17:43:07.381Z",
"reasonDetail": "used for figuring out clipboard contents"
},
{
"rule": "DOM-innerHTML",
"path": "ts/quill/signal-clipboard/index.ts",
"line": " return div.innerHTML;",
"lineNumber": 20,
"reasonCategory": "usageTrusted",
"updated": "2020-11-06T17:43:07.381Z",
"reasonDetail": "used for figuring out clipboard contents"
},
{
"rule": "jQuery-wrap(",
"path": "ts/shims/textsecure.js",