diff --git a/res/css/_common.scss b/res/css/_common.scss index c4cda6821e..38f576a532 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -291,6 +291,10 @@ textarea { vertical-align: middle; } +.mx_emojione_selected { + background-color: $accent-color; +} + ::-moz-selection { background-color: $accent-color; color: $selection-fg-color; diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index 474a123455..5c390af30a 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -25,6 +25,10 @@ padding-right: 5px; } +.mx_UserPill_selected { + background-color: $accent-color ! important; +} + .mx_EventTile_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me, .mx_EventTile_content .mx_AtRoomPill, .mx_MessageComposer_input .mx_AtRoomPill { diff --git a/src/RichText.js b/src/RichText.js index d867636dc9..50ed33d803 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -51,10 +51,6 @@ const MARKDOWN_REGEX = { STRIKETHROUGH: /~{2}[^~]*~{2}/g, }; -const USERNAME_REGEX = /@\S+:\S+/g; -const ROOM_REGEX = /#\S+:\S+/g; -const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); - const ZWS_CODE = 8203; const ZWS = String.fromCharCode(ZWS_CODE); // zero width space @@ -73,7 +69,7 @@ export function htmlToEditorState(html: string): Value { return Html.serialize(html); } -function unicodeToEmojiUri(str) { +export 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 @@ -113,27 +109,6 @@ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: numb } } -// Workaround for https://github.com/facebook/draft-js/issues/414 -const emojiDecorator = { - strategy: (contentState, contentBlock, callback) => { - findWithRegex(EMOJI_REGEX, contentBlock, callback); - }, - component: (props) => { - const uri = unicodeToEmojiUri(props.children[0].props.text); - const shortname = emojione.toShort(props.children[0].props.text); - const 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. */ @@ -223,60 +198,6 @@ export function selectionStateToTextOffsets(selectionState: 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 = newContentState.getEntity(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); - newContentState = newContentState.createEntity( - 'emoji', 'IMMUTABLE', { emojiUnicode: emojiText }, - ); - const entityKey = newContentState.getLastCreatedEntityKey(); - newContentState = Modifier.replaceText( - newContentState, - selection, - emojiText, - null, - entityKey, - ); - }; - - findWithRegex(EMOJI_REGEX, block, addEntityToEmoji); - }); - - if (!newContentState.equals(contentState)) { - const oldSelection = editorState.getSelection(); - editorState = EditorState.push( - editorState, - newContentState, - 'convert-to-immutable-emojis', - ); - // this is somewhat of a hack, we're undoing selection changes caused above - // it would be better not to make those changes in the first place - editorState = EditorState.forceSelection(editorState, oldSelection); - } - - return editorState; -} - export function hasMultiLineSelection(editorState: EditorState): boolean { const selectionState = editorState.getSelection(); const anchorKey = selectionState.getAnchorKey(); diff --git a/src/autocomplete/PlainWithPillsSerializer.js b/src/autocomplete/PlainWithPillsSerializer.js index 6827f1fe73..0e850f2a33 100644 --- a/src/autocomplete/PlainWithPillsSerializer.js +++ b/src/autocomplete/PlainWithPillsSerializer.js @@ -61,6 +61,9 @@ class PlainWithPillsSerializer { (node.object == 'block' && Block.isBlockList(node.nodes)) ) { return node.nodes.map(this._serializeNode).join('\n'); + } + else if (node.type == 'emoji') { + return node.data.get('emojiUnicode'); } else if (node.type == 'pill') { switch (this.pillFormat) { case 'plain': diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index 7e5ad379de..673e4fdd03 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -59,6 +59,8 @@ const Pill = React.createClass({ room: PropTypes.instanceOf(Room), // Whether to include an avatar in the pill shouldShowPillAvatar: PropTypes.bool, + // Whether to render this pill as if it were highlit by a selection + isSelected: PropTypes.bool, }, @@ -233,6 +235,7 @@ const Pill = React.createClass({ const classes = classNames(pillClass, { "mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId, + "mx_UserPill_selected": this.props.isSelected, }); if (this.state.pillType) { diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 4a9dfa4b4c..e682d28ff6 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -58,7 +58,7 @@ import {MATRIXTO_URL_PATTERN, MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-m const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g'); -import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione'; +import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import {makeUserPermalink} from "../../../matrix-to"; import ReplyPreview from "./ReplyPreview"; @@ -69,6 +69,7 @@ import {ContentHelpers} from 'matrix-js-sdk'; const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort(); const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$'); +const EMOJI_REGEX = new RegExp(unicodeRegexp, 'g'); const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; @@ -76,6 +77,7 @@ const ENTITY_TYPES = { AT_ROOM_PILL: 'ATROOMPILL', }; + function onSendMessageFailed(err, room) { // XXX: temporary logging to try to diagnose // https://github.com/vector-im/riot-web/issues/3148 @@ -351,12 +353,20 @@ export default class MessageComposerInput extends React.Component { if (this.direction !== '') { const focusedNode = editorState.focusInline || editorState.focusText; if (focusedNode.isVoid) { - change = change[`collapseToEndOf${ this.direction }Text`](); + if (editorState.isCollapsed) { + change = change[`collapseToEndOf${ this.direction }Text`](); + } + else { + const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText; + if (block) { + change = change.moveFocusToEndOf(block) + } + } editorState = change.value; } } - if (editorState.document.getFirstText().text !== '') { + if (!editorState.document.isEmpty) { this.onTypingActivity(); } else { this.onFinishedTyping(); @@ -369,9 +379,33 @@ export default class MessageComposerInput extends React.Component { } */ -/* - editorState = RichText.attachImmutableEntitiesToEmoji(editorState); + // emojioneify any emoji + // deliberately lose any inlines and pills via Plain.serialize as we know + // they won't contain emoji + // XXX: is getTextsAsArray a private API? + editorState.document.getTextsAsArray().forEach(node => { + if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) { + let match; + while ((match = EMOJI_REGEX.exec(node.text)) !== null) { + const range = Range.create({ + anchorKey: node.key, + anchorOffset: match.index, + focusKey: node.key, + focusOffset: match.index + match[0].length, + }); + const inline = Inline.create({ + type: 'emoji', + data: { emojiUnicode: match[0] }, + isVoid: true, + }); + change = change.insertInlineAtRange(range, inline); + editorState = change.value; + } + } + }); + +/* const currentBlock = editorState.getSelection().getStartKey(); const currentSelection = editorState.getSelection(); const currentStartOffset = editorState.getSelection().getStartOffset(); @@ -400,7 +434,6 @@ export default class MessageComposerInput extends React.Component { editorState = EditorState.forceSelection(editorState, currentSelection); } */ - const text = editorState.startText.text; const currentStartOffset = editorState.startOffset; @@ -912,8 +945,12 @@ export default class MessageComposerInput extends React.Component { // Move selection to the end of the selected history const change = editorState.change().collapseToEndOf(editorState.document); + // XXX: should we be calling this.onChange(change) now? - // we skip it for now given we know we're about to setState anyway + // Answer: yes, if we want it to do any of the fixups on stuff like emoji. + // however, this should already have been done and persisted in the history, + // so shouldn't be necessary. + editorState = change.value; this.suppressAutoComplete = true; @@ -991,11 +1028,6 @@ export default class MessageComposerInput extends React.Component { // we can't put text in here otherwise the editor tries to select it isVoid: true, }); - } else { - inline = Inline.create({ - type: 'autocompletion', - nodes: [Text.create(completion)] - }); } let editorState = activeEditorState; @@ -1007,13 +1039,23 @@ export default class MessageComposerInput extends React.Component { editorState = change.value; } - const change = editorState.change() - .insertInlineAtRange(editorState.selection, inline) - .insertText(suffix); + let change; + if (inline) { + change = editorState.change() + .insertInlineAtRange(editorState.selection, inline) + .insertText(suffix); + } + else { + change = editorState.change() + .insertTextAtRange(editorState.selection, completion) + .insertText(suffix); + } editorState = change.value; - this.setState({ editorState, originalEditorState: activeEditorState }, ()=>{ -// this.refs.editor.focus(); + this.onChange(change); + + this.setState({ + originalEditorState: activeEditorState }); return true; @@ -1027,7 +1069,7 @@ export default class MessageComposerInput extends React.Component { return

{children}

} case 'pill': { - const { data, text } = node; + const { data } = node; const url = data.get('url'); const completion = data.get('completion'); @@ -1039,6 +1081,7 @@ export default class MessageComposerInput extends React.Component { type={Pill.TYPE_AT_ROOM_MENTION} room={this.props.room} shouldShowPillAvatar={shouldShowPillAvatar} + isSelected={isSelected} />; } else if (Pill.isPillUrl(url)) { @@ -1046,14 +1089,26 @@ export default class MessageComposerInput extends React.Component { url={url} room={this.props.room} shouldShowPillAvatar={shouldShowPillAvatar} + isSelected={isSelected} />; } else { + const { text } = node; return { text } ; } } + case 'emoji': { + const { data } = node; + const emojiUnicode = data.get('emojiUnicode'); + const uri = RichText.unicodeToEmojiUri(emojiUnicode); + const shortname = toShort(emojiUnicode); + const className = classNames('mx_emojione', { + mx_emojione_selected: isSelected + }); + return {; + } } };