From b33452216875ba3ffd09747158bd5e1a12512e96 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 8 Jul 2016 12:54:28 +0530 Subject: [PATCH 1/2] feat: code cleanup & emoji replacement in composer --- src/RichText.js | 112 +++++++++++++++--- .../views/rooms/MessageComposerInput.js | 71 +++++------ 2 files changed, 135 insertions(+), 48 deletions(-) diff --git a/src/RichText.js b/src/RichText.js index c24a510e05..a5bc554b95 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -1,6 +1,7 @@ import React from 'react'; import { Editor, + EditorState, Modifier, ContentState, ContentBlock, @@ -9,12 +10,13 @@ import { DefaultDraftInlineStyle, CompositeDecorator, SelectionState, + Entity, } from 'draft-js'; import * as sdk from './index'; import * as emojione from 'emojione'; const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { - element: 'span' + element: 'span', /* draft uses
by default which we don't really like, so we're using this is probably not a good idea since is not a block level element but @@ -65,7 +67,7 @@ export function contentStateToHTML(contentState: ContentState): string { let result = `<${elem}>${content.join('')}`; // dirty hack because we don't want block level tags by default, but breaks - if(elem === 'span') + if (elem === 'span') result += '
'; return result; }).join(''); @@ -75,6 +77,48 @@ export function HTMLtoContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); } +function unicodeToEmojiUri(str) { + let replaceWith, unicode, alt; + if ((!emojione.unicodeAlt) || (emojione.sprites)) { + // if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames + let mappedUnicode = emojione.mapUnicodeToShort(); + } + + str = str.replace(emojione.regUnicode, function(unicodeChar) { + if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) { + // if the unicodeChar doesnt exist just return the entire match + return unicodeChar; + } else { + // get the unicode codepoint from the actual char + unicode = emojione.jsEscapeMap[unicodeChar]; + return emojione.imagePathSVG+unicode+'.svg'+emojione.cacheBustParam; + } + }); + + return str; +} + +// Unused for now, due to https://github.com/facebook/draft-js/issues/414 +let emojiDecorator = { + strategy: (contentBlock, callback) => { + findWithRegex(EMOJI_REGEX, contentBlock, callback); + }, + component: (props) => { + let uri = unicodeToEmojiUri(props.children[0].props.text); + let shortname = emojione.toShort(props.children[0].props.text); + let style = { + display: 'inline-block', + width: '1em', + maxHeight: '1em', + background: `url(${uri})`, + backgroundSize: 'contain', + backgroundPosition: 'center center', + overflow: 'hidden', + }; + return ({props.children}); + }, +}; + /** * Returns a composite decorator which has access to provided scope. */ @@ -90,7 +134,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { // unused until we make these decorators immutable (autocomplete needed) let name = member ? member.name : null; let avatar = member ? : null; - return {avatar} {props.children}; + return {avatar}{props.children}; } }; @@ -103,17 +147,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { } }; - // Unused for now, due to https://github.com/facebook/draft-js/issues/414 - let emojiDecorator = { - strategy: (contentBlock, callback) => { - findWithRegex(EMOJI_REGEX, contentBlock, callback); - }, - component: (props) => { - return - } - }; - - return [usernameDecorator, roomDecorator]; + return [usernameDecorator, roomDecorator, emojiDecorator]; } export function getScopedMDDecorators(scope: any): CompositeDecorator { @@ -139,6 +173,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator { ) }); + markdownDecorators.push(emojiDecorator); return markdownDecorators; } @@ -193,7 +228,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection export function selectionStateToTextOffsets(selectionState: SelectionState, contentBlocks: Array): {start: number, end: number} { let offset = 0, start = 0, end = 0; - for(let block of contentBlocks) { + for (let block of contentBlocks) { if (selectionState.getStartKey() === block.getKey()) { start = offset + selectionState.getStartOffset(); } @@ -240,3 +275,50 @@ export function textOffsetsToSelectionState({start, end}: {start: number, end: n return selectionState; } + +// modified version of https://github.com/draft-js-plugins/draft-js-plugins/blob/master/draft-js-emoji-plugin/src/modifiers/attachImmutableEntitiesToEmojis.js +export function attachImmutableEntitiesToEmoji(editorState: EditorState): EditorState { + const contentState = editorState.getCurrentContent(); + const blocks = contentState.getBlockMap(); + let newContentState = contentState; + + blocks.forEach((block) => { + const plainText = block.getText(); + + const addEntityToEmoji = (start, end) => { + const existingEntityKey = block.getEntityAt(start); + if (existingEntityKey) { + // avoid manipulation in case the emoji already has an entity + const entity = Entity.get(existingEntityKey); + if (entity && entity.get('type') === 'emoji') { + return; + } + } + + const selection = SelectionState.createEmpty(block.getKey()) + .set('anchorOffset', start) + .set('focusOffset', end); + const emojiText = plainText.substring(start, end); + const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText }); + newContentState = Modifier.replaceText( + newContentState, + selection, + emojiText, + null, + entityKey, + ); + }; + + findWithRegex(EMOJI_REGEX, block, addEntityToEmoji); + }); + + if (!newContentState.equals(contentState)) { + return EditorState.push( + editorState, + newContentState, + 'convert-to-immutable-emojis', + ); + } + + return editorState; +} diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 46abc20ed6..fea4e8fea0 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; - -var marked = require("marked"); +import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent'; +import marked from 'marked'; marked.setOptions({ renderer: new marked.Renderer(), gfm: true, @@ -24,7 +24,7 @@ marked.setOptions({ pedantic: false, sanitize: true, smartLists: true, - smartypants: false + smartypants: false, }); import {Editor, EditorState, RichUtils, CompositeDecorator, @@ -33,14 +33,14 @@ import {Editor, EditorState, RichUtils, CompositeDecorator, import {stateToMarkdown} from 'draft-js-export-markdown'; -var MatrixClientPeg = require("../../../MatrixClientPeg"); -var SlashCommands = require("../../../SlashCommands"); -var Modal = require("../../../Modal"); -var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; -var sdk = require('../../../index'); +import MatrixClientPeg from '../../../MatrixClientPeg'; +import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; +import SlashCommands from '../../../SlashCommands'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; -var dis = require("../../../dispatcher"); -var KeyCode = require("../../../KeyCode"); +import dis from '../../../dispatcher'; +import KeyCode from '../../../KeyCode'; import * as RichText from '../../../RichText'; @@ -49,8 +49,8 @@ const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const KEY_M = 77; // FIXME Breaks markdown with multiple paragraphs, since it only strips first and last

-function mdownToHtml(mdown) { - var html = marked(mdown) || ""; +function mdownToHtml(mdown: string): string { + let html = marked(mdown) || ""; html = html.trim(); // strip start and end

tags else you get 'orrible spacing if (html.indexOf("

") === 0) { @@ -66,6 +66,17 @@ function mdownToHtml(mdown) { * The textInput part of the MessageComposer */ export default class MessageComposerInput extends React.Component { + static getKeyBinding(e: SyntheticKeyboardEvent): string { + // C-m => Toggles between rich text and markdown modes + if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { + return 'toggle-mode'; + } + + return getDefaultKeyBinding(e); + } + + client: MatrixClient; + constructor(props, context) { super(props, context); this.onAction = this.onAction.bind(this); @@ -79,7 +90,7 @@ export default class MessageComposerInput extends React.Component { this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); - if(isRichtextEnabled == null) { + if (isRichtextEnabled == null) { isRichtextEnabled = 'true'; } isRichtextEnabled = isRichtextEnabled === 'true'; @@ -95,15 +106,6 @@ export default class MessageComposerInput extends React.Component { this.client = MatrixClientPeg.get(); } - static getKeyBinding(e: SyntheticKeyboardEvent): string { - // C-m => Toggles between rich text and markdown modes - if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { - return 'toggle-mode'; - } - - return getDefaultKeyBinding(e); - } - /** * "Does the right thing" to create an EditorState, based on: * - whether we've got rich text mode enabled @@ -347,15 +349,16 @@ export default class MessageComposerInput extends React.Component { } setEditorState(editorState: EditorState) { + editorState = RichText.attachImmutableEntitiesToEmoji(editorState); this.setState({editorState}); - if(editorState.getCurrentContent().hasText()) { - this.onTypingActivity() + if (editorState.getCurrentContent().hasText()) { + this.onTypingActivity(); } else { this.onFinishedTyping(); } - if(this.props.onContentChanged) { + if (this.props.onContentChanged) { this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), RichText.selectionStateToTextOffsets(editorState.getSelection(), editorState.getCurrentContent().getBlocksAsArray())); @@ -380,7 +383,7 @@ export default class MessageComposerInput extends React.Component { } handleKeyCommand(command: string): boolean { - if(command === 'toggle-mode') { + if (command === 'toggle-mode') { this.enableRichtext(!this.state.isRichtextEnabled); return true; } @@ -388,7 +391,7 @@ export default class MessageComposerInput extends React.Component { let newState: ?EditorState = null; // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. - if(!this.state.isRichtextEnabled) { + if (!this.state.isRichtextEnabled) { let contentState = this.state.editorState.getCurrentContent(), selection = this.state.editorState.getSelection(); @@ -396,10 +399,10 @@ export default class MessageComposerInput extends React.Component { bold: text => `**${text}**`, italic: text => `*${text}*`, underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* - code: text => `\`${text}\`` + code: text => `\`${text}\``, }[command]; - if(modifyFn) { + if (modifyFn) { newState = EditorState.push( this.state.editorState, RichText.modifyText(contentState, selection, modifyFn), @@ -408,7 +411,7 @@ export default class MessageComposerInput extends React.Component { } } - if(newState == null) + if (newState == null) newState = RichUtils.handleKeyCommand(this.state.editorState, command); if (newState != null) { @@ -533,9 +536,11 @@ export default class MessageComposerInput extends React.Component { content ); - this.setState({ - editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'), - }); + let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); + + editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); + + this.setEditorState(editorState); // for some reason, doing this right away does not update the editor :( setTimeout(() => this.refs.editor.focus(), 50); From 8e66e6dfdd7c82c626bb90e1c7432d3693255ec1 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Wed, 3 Aug 2016 18:27:49 +0530 Subject: [PATCH 2/2] fix: Switch to opacity: 0 for composer emoji. This seems to be the best option for displaying emoji in the composer. While it means selected emoji don't actually have the selection colour applied, it's the most functional of all the options. Facebook uses the same approach. --- src/RichText.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RichText.js b/src/RichText.js index a5bc554b95..7cd78a14c9 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -98,7 +98,7 @@ function unicodeToEmojiUri(str) { return str; } -// Unused for now, due to https://github.com/facebook/draft-js/issues/414 +// Workaround for https://github.com/facebook/draft-js/issues/414 let emojiDecorator = { strategy: (contentBlock, callback) => { findWithRegex(EMOJI_REGEX, contentBlock, callback); @@ -115,7 +115,7 @@ let emojiDecorator = { backgroundPosition: 'center center', overflow: 'hidden', }; - return ({props.children}); + return ({props.children}); }, };