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;
|
right: 0;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-blot {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,11 +31,13 @@ import {
|
||||||
isMentionBlot,
|
isMentionBlot,
|
||||||
getDeltaToRestartMention,
|
getDeltaToRestartMention,
|
||||||
} from '../quill/util';
|
} from '../quill/util';
|
||||||
|
import { SignalClipboard } from '../quill/signal-clipboard';
|
||||||
|
|
||||||
Quill.register('formats/emoji', EmojiBlot);
|
Quill.register('formats/emoji', EmojiBlot);
|
||||||
Quill.register('formats/mention', MentionBlot);
|
Quill.register('formats/mention', MentionBlot);
|
||||||
Quill.register('modules/emojiCompletion', EmojiCompletion);
|
Quill.register('modules/emojiCompletion', EmojiCompletion);
|
||||||
Quill.register('modules/mentionCompletion', MentionCompletion);
|
Quill.register('modules/mentionCompletion', MentionCompletion);
|
||||||
|
Quill.register('modules/signalClipboard', SignalClipboard);
|
||||||
|
|
||||||
const Block = Quill.import('blots/block');
|
const Block = Quill.import('blots/block');
|
||||||
Block.tagName = 'DIV';
|
Block.tagName = 'DIV';
|
||||||
|
@ -556,10 +558,11 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
defaultValue={delta}
|
defaultValue={delta}
|
||||||
modules={{
|
modules={{
|
||||||
toolbar: false,
|
toolbar: false,
|
||||||
|
signalClipboard: true,
|
||||||
clipboard: {
|
clipboard: {
|
||||||
matchers: [
|
matchers: [
|
||||||
['IMG', matchEmojiImage],
|
['IMG', matchEmojiImage],
|
||||||
['SPAN', matchEmojiBlot],
|
['IMG', matchEmojiBlot],
|
||||||
['SPAN', matchReactEmoji],
|
['SPAN', matchReactEmoji],
|
||||||
['SPAN', matchMention(memberRepositoryRef)],
|
['SPAN', matchMention(memberRepositoryRef)],
|
||||||
],
|
],
|
||||||
|
|
|
@ -11,6 +11,9 @@ import { RenderTextCallbackType } from '../../types/Util';
|
||||||
import { emojiToImage, SizeClassType } from '../emoji/lib';
|
import { emojiToImage, SizeClassType } from '../emoji/lib';
|
||||||
|
|
||||||
// Some of this logic taken from emoji-js/replacement
|
// 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({
|
function getImageTag({
|
||||||
match,
|
match,
|
||||||
sizeClass,
|
sizeClass,
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { storiesOf } from '@storybook/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';
|
import { Emoji, EmojiSizes, Props } from './Emoji';
|
||||||
|
|
||||||
const story = storiesOf('Components/Emoji/Emoji', module);
|
const story = storiesOf('Components/Emoji/Emoji', module);
|
||||||
|
@ -16,7 +16,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
EmojiSizes.reduce((m, t) => ({ ...m, [t]: t }), {}),
|
EmojiSizes.reduce((m, t) => ({ ...m, [t]: t }), {}),
|
||||||
overrideProps.size || 48
|
overrideProps.size || 48
|
||||||
),
|
),
|
||||||
inline: boolean('inline', overrideProps.inline || false),
|
|
||||||
emoji: text('emoji', overrideProps.emoji || ''),
|
emoji: text('emoji', overrideProps.emoji || ''),
|
||||||
shortName: text('shortName', overrideProps.shortName || ''),
|
shortName: text('shortName', overrideProps.shortName || ''),
|
||||||
skinTone: select(
|
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', () => {
|
story.add('From Emoji', () => {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
emoji: '😂',
|
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 EmojiSizeType = typeof EmojiSizes[number];
|
||||||
|
|
||||||
export type OwnProps = {
|
export type OwnProps = {
|
||||||
inline?: boolean;
|
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
shortName?: string;
|
shortName?: string;
|
||||||
skinTone?: SkinToneKey | number;
|
skinTone?: SkinToneKey | number;
|
||||||
|
@ -21,19 +20,14 @@ export type OwnProps = {
|
||||||
export type Props = OwnProps &
|
export type Props = OwnProps &
|
||||||
Pick<React.HTMLProps<HTMLDivElement>, 'style' | 'className'>;
|
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(
|
export const Emoji = React.memo(
|
||||||
React.forwardRef<HTMLDivElement, Props>(
|
React.forwardRef<HTMLDivElement, Props>(
|
||||||
(
|
(
|
||||||
{
|
{ style = {}, size = 28, shortName, skinTone, emoji, className }: Props,
|
||||||
style = {},
|
|
||||||
size = 28,
|
|
||||||
shortName,
|
|
||||||
skinTone,
|
|
||||||
emoji,
|
|
||||||
inline,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: Props,
|
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
let image = '';
|
let image = '';
|
||||||
|
@ -43,32 +37,22 @@ export const Emoji = React.memo(
|
||||||
image = emojiToImage(emoji) || '';
|
image = emojiToImage(emoji) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const backgroundStyle = inline
|
|
||||||
? { backgroundImage: `url('${image}')` }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-emoji',
|
'module-emoji',
|
||||||
`module-emoji--${size}px`,
|
`module-emoji--${size}px`,
|
||||||
inline ? `module-emoji--${size}px--inline` : null,
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ ...style, ...backgroundStyle }}
|
style={style}
|
||||||
>
|
>
|
||||||
{inline ? (
|
<img
|
||||||
// When using this component as in a CompositionInput it is very
|
className={`module-emoji__image--${size}px`}
|
||||||
// important that these children are the only elements to render
|
src={image}
|
||||||
children
|
aria-label={emoji}
|
||||||
) : (
|
title={emoji}
|
||||||
<img
|
/>
|
||||||
className={`module-emoji__image--${size}px`}
|
|
||||||
src={image}
|
|
||||||
alt={shortName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -383,7 +383,6 @@ export const EmojiPicker = React.memo(
|
||||||
<Emoji
|
<Emoji
|
||||||
shortName="slightly_frowning_face"
|
shortName="slightly_frowning_face"
|
||||||
size={16}
|
size={16}
|
||||||
inline
|
|
||||||
style={{ marginLeft: '4px' }}
|
style={{ marginLeft: '4px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import Parchment from 'parchment';
|
import Parchment from 'parchment';
|
||||||
import Quill from 'quill';
|
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');
|
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 {
|
export class EmojiBlot extends Embed {
|
||||||
static blotName = 'emoji';
|
static blotName = 'emoji';
|
||||||
|
|
||||||
static tagName = 'span';
|
static tagName = 'img';
|
||||||
|
|
||||||
static className = 'emoji-blot';
|
static className = 'emoji-blot';
|
||||||
|
|
||||||
|
@ -21,14 +23,12 @@ export class EmojiBlot extends Embed {
|
||||||
const node = super.create(undefined) as HTMLElement;
|
const node = super.create(undefined) as HTMLElement;
|
||||||
node.dataset.emoji = emoji;
|
node.dataset.emoji = emoji;
|
||||||
|
|
||||||
const emojiSpan = document.createElement('span');
|
const image = emojiToImage(emoji);
|
||||||
render(
|
|
||||||
<Emoji emoji={emoji} inline size={20}>
|
node.setAttribute('src', image || '');
|
||||||
{emoji}
|
node.setAttribute('data-emoji', emoji);
|
||||||
</Emoji>,
|
node.setAttribute('title', emoji);
|
||||||
emojiSpan
|
node.setAttribute('aria-label', emoji);
|
||||||
);
|
|
||||||
node.appendChild(emojiSpan);
|
|
||||||
|
|
||||||
return node;
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { LeafBlot } from 'quill';
|
import { LeafBlot, DeltaOperation } from 'quill';
|
||||||
import Op from 'quill-delta/dist/Op';
|
import Op from 'quill-delta/dist/Op';
|
||||||
|
|
||||||
import { BodyRangeType } from '../types/Util';
|
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 =>
|
export const isInsertMentionOp = (op: Op): op is InsertMentionOp =>
|
||||||
isSpecificInsertOp(op, 'mention');
|
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 = (
|
export const getTextAndMentionsFromOps = (
|
||||||
ops: Array<Op>
|
ops: Array<Op>
|
||||||
): [string, Array<BodyRangeType>] => {
|
): [string, Array<BodyRangeType>] => {
|
||||||
|
|
|
@ -14544,24 +14544,6 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const emojiCompletionRef = React.useRef();",
|
"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,
|
"lineNumber": 45,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
|
@ -14570,16 +14552,16 @@
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const scrollerRef = React.useRef(null);",
|
"line": " const mentionCompletionRef = React.useRef();",
|
||||||
"lineNumber": 46,
|
"lineNumber": 46,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T23:54:34.273Z",
|
||||||
"reasonDetail": "Used with Quill for scrolling."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const propsRef = React.useRef(props);",
|
"line": " const quillRef = React.useRef();",
|
||||||
"lineNumber": 47,
|
"lineNumber": 47,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
|
@ -14588,8 +14570,26 @@
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
|
"line": " const scrollerRef = React.useRef(null);",
|
||||||
"lineNumber": 48,
|
"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",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T23:56:13.482Z",
|
"updated": "2020-10-26T23:56:13.482Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -14910,6 +14910,24 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-30T23:03:08.319Z"
|
"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(",
|
"rule": "jQuery-wrap(",
|
||||||
"path": "ts/shims/textsecure.js",
|
"path": "ts/shims/textsecure.js",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue