feat: code cleanup & emoji replacement in composer

This commit is contained in:
Aviral Dasgupta 2016-07-08 12:54:28 +05:30
parent a2b64798f7
commit b334522168
2 changed files with 135 additions and 48 deletions

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { import {
Editor, Editor,
EditorState,
Modifier, Modifier,
ContentState, ContentState,
ContentBlock, ContentBlock,
@ -9,12 +10,13 @@ import {
DefaultDraftInlineStyle, DefaultDraftInlineStyle,
CompositeDecorator, CompositeDecorator,
SelectionState, SelectionState,
Entity,
} from 'draft-js'; } from 'draft-js';
import * as sdk from './index'; import * as sdk from './index';
import * as emojione from 'emojione'; import * as emojione from 'emojione';
const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', {
element: 'span' element: 'span',
/* /*
draft uses <div> by default which we don't really like, so we're using <span> draft uses <div> by default which we don't really like, so we're using <span>
this is probably not a good idea since <span> is not a block level element but this is probably not a good idea since <span> is not a block level element but
@ -65,7 +67,7 @@ export function contentStateToHTML(contentState: ContentState): string {
let result = `<${elem}>${content.join('')}</${elem}>`; let result = `<${elem}>${content.join('')}</${elem}>`;
// dirty hack because we don't want block level tags by default, but breaks // dirty hack because we don't want block level tags by default, but breaks
if(elem === 'span') if (elem === 'span')
result += '<br />'; result += '<br />';
return result; return result;
}).join(''); }).join('');
@ -75,6 +77,48 @@ export function HTMLtoContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html)); 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 (<span title={shortname} style={style}><span style={{color: 'transparent'}}>{props.children}</span></span>);
},
};
/** /**
* Returns a composite decorator which has access to provided scope. * 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) // unused until we make these decorators immutable (autocomplete needed)
let name = member ? member.name : null; let name = member ? member.name : null;
let avatar = member ? <MemberAvatar member={member} width={16} height={16}/> : null; let avatar = member ? <MemberAvatar member={member} width={16} height={16}/> : null;
return <span className="mx_UserPill">{avatar} {props.children}</span>; return <span className="mx_UserPill">{avatar}{props.children}</span>;
} }
}; };
@ -103,17 +147,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
} }
}; };
// Unused for now, due to https://github.com/facebook/draft-js/issues/414 return [usernameDecorator, roomDecorator, emojiDecorator];
let emojiDecorator = {
strategy: (contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback);
},
component: (props) => {
return <span dangerouslySetInnerHTML={{__html: ' ' + emojione.unicodeToImage(props.children[0].props.text)}}/>
}
};
return [usernameDecorator, roomDecorator];
} }
export function getScopedMDDecorators(scope: any): CompositeDecorator { export function getScopedMDDecorators(scope: any): CompositeDecorator {
@ -139,6 +173,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
</a> </a>
) )
}); });
markdownDecorators.push(emojiDecorator);
return markdownDecorators; return markdownDecorators;
} }
@ -193,7 +228,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
export function selectionStateToTextOffsets(selectionState: SelectionState, export function selectionStateToTextOffsets(selectionState: SelectionState,
contentBlocks: Array<ContentBlock>): {start: number, end: number} { contentBlocks: Array<ContentBlock>): {start: number, end: number} {
let offset = 0, start = 0, end = 0; let offset = 0, start = 0, end = 0;
for(let block of contentBlocks) { for (let block of contentBlocks) {
if (selectionState.getStartKey() === block.getKey()) { if (selectionState.getStartKey() === block.getKey()) {
start = offset + selectionState.getStartOffset(); start = offset + selectionState.getStartOffset();
} }
@ -240,3 +275,50 @@ export function textOffsetsToSelectionState({start, end}: {start: number, end: n
return selectionState; 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;
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
var marked = require("marked"); import marked from 'marked';
marked.setOptions({ marked.setOptions({
renderer: new marked.Renderer(), renderer: new marked.Renderer(),
gfm: true, gfm: true,
@ -24,7 +24,7 @@ marked.setOptions({
pedantic: false, pedantic: false,
sanitize: true, sanitize: true,
smartLists: true, smartLists: true,
smartypants: false smartypants: false,
}); });
import {Editor, EditorState, RichUtils, CompositeDecorator, import {Editor, EditorState, RichUtils, CompositeDecorator,
@ -33,14 +33,14 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
import {stateToMarkdown} from 'draft-js-export-markdown'; import {stateToMarkdown} from 'draft-js-export-markdown';
var MatrixClientPeg = require("../../../MatrixClientPeg"); import MatrixClientPeg from '../../../MatrixClientPeg';
var SlashCommands = require("../../../SlashCommands"); import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
var Modal = require("../../../Modal"); import SlashCommands from '../../../SlashCommands';
var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; import Modal from '../../../Modal';
var sdk = require('../../../index'); import sdk from '../../../index';
var dis = require("../../../dispatcher"); import dis from '../../../dispatcher';
var KeyCode = require("../../../KeyCode"); import KeyCode from '../../../KeyCode';
import * as RichText from '../../../RichText'; import * as RichText from '../../../RichText';
@ -49,8 +49,8 @@ const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
const KEY_M = 77; const KEY_M = 77;
// FIXME Breaks markdown with multiple paragraphs, since it only strips first and last <p> // FIXME Breaks markdown with multiple paragraphs, since it only strips first and last <p>
function mdownToHtml(mdown) { function mdownToHtml(mdown: string): string {
var html = marked(mdown) || ""; let html = marked(mdown) || "";
html = html.trim(); html = html.trim();
// strip start and end <p> tags else you get 'orrible spacing // strip start and end <p> tags else you get 'orrible spacing
if (html.indexOf("<p>") === 0) { if (html.indexOf("<p>") === 0) {
@ -66,6 +66,17 @@ function mdownToHtml(mdown) {
* The textInput part of the MessageComposer * The textInput part of the MessageComposer
*/ */
export default class MessageComposerInput extends React.Component { 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) { constructor(props, context) {
super(props, context); super(props, context);
this.onAction = this.onAction.bind(this); this.onAction = this.onAction.bind(this);
@ -79,7 +90,7 @@ export default class MessageComposerInput extends React.Component {
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this);
let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled');
if(isRichtextEnabled == null) { if (isRichtextEnabled == null) {
isRichtextEnabled = 'true'; isRichtextEnabled = 'true';
} }
isRichtextEnabled = isRichtextEnabled === 'true'; isRichtextEnabled = isRichtextEnabled === 'true';
@ -95,15 +106,6 @@ export default class MessageComposerInput extends React.Component {
this.client = MatrixClientPeg.get(); 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: * "Does the right thing" to create an EditorState, based on:
* - whether we've got rich text mode enabled * - whether we've got rich text mode enabled
@ -347,15 +349,16 @@ export default class MessageComposerInput extends React.Component {
} }
setEditorState(editorState: EditorState) { setEditorState(editorState: EditorState) {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
this.setState({editorState}); this.setState({editorState});
if(editorState.getCurrentContent().hasText()) { if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity() this.onTypingActivity();
} else { } else {
this.onFinishedTyping(); this.onFinishedTyping();
} }
if(this.props.onContentChanged) { if (this.props.onContentChanged) {
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), this.props.onContentChanged(editorState.getCurrentContent().getPlainText(),
RichText.selectionStateToTextOffsets(editorState.getSelection(), RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray())); editorState.getCurrentContent().getBlocksAsArray()));
@ -380,7 +383,7 @@ export default class MessageComposerInput extends React.Component {
} }
handleKeyCommand(command: string): boolean { handleKeyCommand(command: string): boolean {
if(command === 'toggle-mode') { if (command === 'toggle-mode') {
this.enableRichtext(!this.state.isRichtextEnabled); this.enableRichtext(!this.state.isRichtextEnabled);
return true; return true;
} }
@ -388,7 +391,7 @@ export default class MessageComposerInput extends React.Component {
let newState: ?EditorState = null; let newState: ?EditorState = null;
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. // 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(), let contentState = this.state.editorState.getCurrentContent(),
selection = this.state.editorState.getSelection(); selection = this.state.editorState.getSelection();
@ -396,10 +399,10 @@ export default class MessageComposerInput extends React.Component {
bold: text => `**${text}**`, bold: text => `**${text}**`,
italic: text => `*${text}*`, italic: text => `*${text}*`,
underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
code: text => `\`${text}\`` code: text => `\`${text}\``,
}[command]; }[command];
if(modifyFn) { if (modifyFn) {
newState = EditorState.push( newState = EditorState.push(
this.state.editorState, this.state.editorState,
RichText.modifyText(contentState, selection, modifyFn), 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); newState = RichUtils.handleKeyCommand(this.state.editorState, command);
if (newState != null) { if (newState != null) {
@ -533,9 +536,11 @@ export default class MessageComposerInput extends React.Component {
content content
); );
this.setState({ let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
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 :( // for some reason, doing this right away does not update the editor :(
setTimeout(() => this.refs.editor.focus(), 50); setTimeout(() => this.refs.editor.focus(), 50);