Improve emoji blot and override clipboard behavior
This commit is contained in:
parent
d4d9688447
commit
91beef7797
10 changed files with 181 additions and 84 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)],
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: '😂',
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -383,7 +383,6 @@ export const EmojiPicker = React.memo(
|
|||
<Emoji
|
||||
shortName="slightly_frowning_face"
|
||||
size={16}
|
||||
inline
|
||||
style={{ marginLeft: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
68
ts/quill/signal-clipboard/index.ts
Normal file
68
ts/quill/signal-clipboard/index.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>] => {
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue