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);