From 91beef7797308c84c504e35c0f43eb566565e0cd Mon Sep 17 00:00:00 2001 From: Sidney Keese Date: Fri, 6 Nov 2020 12:11:18 -0800 Subject: [PATCH] Improve emoji blot and override clipboard behavior --- stylesheets/_modules.scss | 6 +++ ts/components/CompositionInput.tsx | 5 +- ts/components/conversation/Emojify.tsx | 3 ++ ts/components/emoji/Emoji.stories.tsx | 18 +------ ts/components/emoji/Emoji.tsx | 40 +++++---------- ts/components/emoji/EmojiPicker.tsx | 1 - ts/quill/emoji/blot.tsx | 24 ++++----- ts/quill/signal-clipboard/index.ts | 68 ++++++++++++++++++++++++++ ts/quill/util.ts | 34 ++++++++++++- ts/util/lint/exceptions.json | 66 ++++++++++++++++--------- 10 files changed, 181 insertions(+), 84 deletions(-) create mode 100644 ts/quill/signal-clipboard/index.ts diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e4ef836731..8fb19cf7e6 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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; + } } } diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index a5d8a7bfe4..117e3eee03 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -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 => { defaultValue={delta} modules={{ toolbar: false, + signalClipboard: true, clipboard: { matchers: [ ['IMG', matchEmojiImage], - ['SPAN', matchEmojiBlot], + ['IMG', matchEmojiBlot], ['SPAN', matchReactEmoji], ['SPAN', matchMention(memberRepositoryRef)], ], diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx index 53634ff803..8a28c2544b 100644 --- a/ts/components/conversation/Emojify.tsx +++ b/ts/components/conversation/Emojify.tsx @@ -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, diff --git a/ts/components/emoji/Emoji.stories.tsx b/ts/components/emoji/Emoji.stories.tsx index bfd263aa76..85c609833b 100644 --- a/ts/components/emoji/Emoji.stories.tsx +++ b/ts/components/emoji/Emoji.stories.tsx @@ -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 => ({ 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 ( - <> - - - - - ); -}); - story.add('From Emoji', () => { const props = createProps({ emoji: '😂', diff --git a/ts/components/emoji/Emoji.tsx b/ts/components/emoji/Emoji.tsx index bcbbf7f9fb..a7f46e8808 100644 --- a/ts/components/emoji/Emoji.tsx +++ b/ts/components/emoji/Emoji.tsx @@ -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, '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( ( - { - 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 ( - {inline ? ( - // When using this component as in a CompositionInput it is very - // important that these children are the only elements to render - children - ) : ( - {shortName} - )} + ); } diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx index 1cb344bbe6..8d8af430f7 100644 --- a/ts/components/emoji/EmojiPicker.tsx +++ b/ts/components/emoji/EmojiPicker.tsx @@ -383,7 +383,6 @@ export const EmojiPicker = React.memo( diff --git a/ts/quill/emoji/blot.tsx b/ts/quill/emoji/blot.tsx index cb6e56f0c0..859e58a5a1 100644 --- a/ts/quill/emoji/blot.tsx +++ b/ts/quill/emoji/blot.tsx @@ -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} - , - 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; } diff --git a/ts/quill/signal-clipboard/index.ts b/ts/quill/signal-clipboard/index.ts new file mode 100644 index 0000000000..3ac0cdafa6 --- /dev/null +++ b/ts/quill/signal-clipboard/index.ts @@ -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'); + } + } +} diff --git a/ts/quill/util.ts b/ts/quill/util.ts index 1a2d70cc47..570fc9f5fa 100644 --- a/ts/quill/util.ts +++ b/ts/quill/util.ts @@ -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): 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 ): [string, Array] => { diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index a7938f058c..ce211c1a54 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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",