Merge pull request #440 from aviraldg/feature-rte-formatbar

Formatting toolbar for RTE message composer.
This commit is contained in:
Matthew Hodgson 2016-09-08 13:54:26 +01:00 committed by GitHub
commit b0a4b017c3
6 changed files with 277 additions and 131 deletions

View file

@ -26,10 +26,9 @@
"dependencies": { "dependencies": {
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"classnames": "^2.1.2", "classnames": "^2.1.2",
"draft-js": "^0.7.0", "draft-js": "^0.8.1",
"draft-js-export-html": "^0.2.2", "draft-js-export-html": "^0.4.0",
"draft-js-export-markdown": "^0.2.0", "draft-js-export-markdown": "^0.2.0",
"draft-js-import-markdown": "^0.1.6",
"emojione": "2.2.3", "emojione": "2.2.3",
"favico.js": "^0.3.10", "favico.js": "^0.3.10",
"filesize": "^3.1.2", "filesize": "^3.1.2",

View file

@ -14,64 +14,22 @@ import {
} 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';
import {stateToHTML} from 'draft-js-export-html';
const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', {
element: '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
we're trying to fix things in contentStateToHTML below
*/
});
const STYLES = {
BOLD: 'strong',
CODE: 'code',
ITALIC: 'em',
STRIKETHROUGH: 's',
UNDERLINE: 'u',
};
const MARKDOWN_REGEX = { const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\1/g, ITALIC: /([\*_])([\w\s]+?)\1/g,
BOLD: /([\*_])\1([\w\s]+?)\1\1/g, BOLD: /([\*_])\1([\w\s]+?)\1\1/g,
HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g,
CODE: /`[^`]*`/g,
STRIKETHROUGH: /~{2}[^~]*~{2}/g,
}; };
const USERNAME_REGEX = /@\S+:\S+/g; const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export function contentStateToHTML(contentState: ContentState): string { export const contentStateToHTML = stateToHTML;
return contentState.getBlockMap().map((block) => {
let elem = BLOCK_RENDER_MAP.get(block.getType()).element;
let content = [];
block.findStyleRanges(
() => true, // always return true => don't filter any ranges out
(start, end) => {
// map style names to elements
let tags = block.getInlineStyleAt(start).map(style => STYLES[style]).filter(style => !!style);
// combine them to get well-nested HTML
let open = tags.map(tag => `<${tag}>`).join('');
let close = tags.map(tag => `</${tag}>`).reverse().join('');
// and get the HTML representation of this styled range (this .substring() should never fail)
let text = block.getText().substring(start, end);
// http://shebang.brandonmintern.com/foolproof-html-escaping-in-javascript/
let div = document.createElement('div');
div.appendChild(document.createTextNode(text));
let safeText = div.innerHTML;
content.push(`${open}${safeText}${close}`);
}
);
let result = `<${elem}>${content.join('')}</${elem}>`;
// dirty hack because we don't want block level tags by default, but breaks
if (elem === 'span')
result += '<br />';
return result;
}).join('');
}
export function HTMLtoContentState(html: string): ContentState { export function HTMLtoContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html)); return ContentState.createFromBlockArray(convertFromHTML(html));
@ -98,6 +56,19 @@ function unicodeToEmojiUri(str) {
return str; return str;
} }
/**
* Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end)
* From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html
*/
function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) {
const text = contentBlock.getText();
let matchArr, start;
while ((matchArr = regex.exec(text)) !== null) {
start = matchArr.index;
callback(start, start + matchArr[0].length);
}
}
// Workaround for https://github.com/facebook/draft-js/issues/414 // Workaround for https://github.com/facebook/draft-js/issues/414
let emojiDecorator = { let emojiDecorator = {
strategy: (contentBlock, callback) => { strategy: (contentBlock, callback) => {
@ -151,7 +122,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
} }
export function getScopedMDDecorators(scope: any): CompositeDecorator { export function getScopedMDDecorators(scope: any): CompositeDecorator {
let markdownDecorators = ['BOLD', 'ITALIC'].map( let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({ (style) => ({
strategy: (contentBlock, callback) => { strategy: (contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
@ -178,19 +149,6 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
return markdownDecorators; return markdownDecorators;
} }
/**
* Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end)
* From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html
*/
function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) {
const text = contentBlock.getText();
let matchArr, start;
while ((matchArr = regex.exec(text)) !== null) {
start = matchArr.index;
callback(start, start + matchArr[0].length);
}
}
/** /**
* Passes rangeToReplace to modifyFn and replaces it in contentState with the result. * Passes rangeToReplace to modifyFn and replaces it in contentState with the result.
*/ */

View file

@ -130,9 +130,9 @@ module.exports = {
return event ? event.getContent() : {}; return event ? event.getContent() : {};
}, },
getSyncedSetting: function(type) { getSyncedSetting: function(type, defaultValue = null) {
var settings = this.getSyncedSettings(); var settings = this.getSyncedSettings();
return settings[type]; return settings.hasOwnProperty(type) ? settings[type] : null;
}, },
setSyncedSetting: function(type, value) { setSyncedSetting: function(type, value) {

View file

@ -149,13 +149,13 @@ export default class Autocomplete extends React.Component {
{completionResult.provider.renderCompletions(completions)} {completionResult.provider.renderCompletions(completions)}
</div> </div>
) : null; ) : null;
}); }).filter(completion => !!completion);
return ( return renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}> <div className="mx_Autocomplete" ref={(e) => this.container = e}>
{renderedCompletions} {renderedCompletions}
</div> </div>
); ) : null;
} }
} }

View file

@ -21,6 +21,7 @@ var Modal = require('../../../Modal');
var sdk = require('../../../index'); var sdk = require('../../../index');
var dis = require('../../../dispatcher'); var dis = require('../../../dispatcher');
import Autocomplete from './Autocomplete'; import Autocomplete from './Autocomplete';
import classNames from 'classnames';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
@ -38,10 +39,20 @@ export default class MessageComposer extends React.Component {
this.onDownArrow = this.onDownArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this);
this._tryComplete = this._tryComplete.bind(this); this._tryComplete = this._tryComplete.bind(this);
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this); this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this);
this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
this.onInputStateChanged = this.onInputStateChanged.bind(this);
this.state = { this.state = {
autocompleteQuery: '', autocompleteQuery: '',
selection: null, selection: null,
inputState: {
style: [],
blockType: null,
isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true),
wordCount: 0,
},
showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false),
}; };
} }
@ -134,6 +145,10 @@ export default class MessageComposer extends React.Component {
}); });
} }
onInputStateChanged(inputState) {
this.setState({inputState});
}
onUpArrow() { onUpArrow() {
return this.refs.autocomplete.onUpArrow(); return this.refs.autocomplete.onUpArrow();
} }
@ -155,6 +170,21 @@ export default class MessageComposer extends React.Component {
} }
} }
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", event) {
event.preventDefault();
this.messageComposerInput.onFormatButtonClicked(name, event);
}
onToggleFormattingClicked() {
UserSettingsStore.setSyncedSetting('MessageComposer.showFormatting', !this.state.showFormatting);
this.setState({showFormatting: !this.state.showFormatting});
}
onToggleMarkdownClicked(e) {
e.preventDefault(); // don't steal focus from the editor!
this.messageComposerInput.enableRichtext(!this.state.inputState.isRichtextEnabled);
}
render() { render() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'}; var uploadInputStyle = {display: 'none'};
@ -207,6 +237,16 @@ export default class MessageComposer extends React.Component {
</div> </div>
); );
const formattingButton = (
<img className="mx_MessageComposer_formatting"
title="Show Text Formatting Toolbar"
src="img/button-text-formatting.svg"
onClick={this.onToggleFormattingClicked}
style={{visibility: this.state.showFormatting ||
!UserSettingsStore.isFeatureEnabled('rich_text_editor') ? 'hidden' : 'visible'}}
key="controls_formatting" />
);
controls.push( controls.push(
<MessageComposerInput <MessageComposerInput
ref={c => this.messageComposerInput = c} ref={c => this.messageComposerInput = c}
@ -217,7 +257,9 @@ export default class MessageComposer extends React.Component {
onUpArrow={this.onUpArrow} onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow} onDownArrow={this.onDownArrow}
tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete
onContentChanged={this.onInputContentChanged} />, onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />,
formattingButton,
uploadButton, uploadButton,
hangupButton, hangupButton,
callButton, callButton,
@ -242,6 +284,26 @@ export default class MessageComposer extends React.Component {
</div>; </div>;
} }
const {style, blockType} = this.state.inputState;
const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map(
name => {
const active = style.includes(name) || blockType === name;
const suffix = active ? '-o-n' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name;
const className = classNames("mx_MessageComposer_format_button", {
mx_MessageComposer_format_button_disabled: disabled,
});
return <img className={className}
title={name}
onMouseDown={disabled ? null : onFormatButtonClicked}
key={name}
src={`img/button-text-${name}${suffix}.svg`}
height="17" />;
},
);
return ( return (
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}> <div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}>
{autoComplete} {autoComplete}
@ -250,6 +312,22 @@ export default class MessageComposer extends React.Component {
{controls} {controls}
</div> </div>
</div> </div>
{UserSettingsStore.isFeatureEnabled('rich_text_editor') ?
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar" style={this.state.showFormatting ? {} : {display: 'none'}}>
{formatButtons}
<div style={{flex: 1}}></div>
<img title={`Turn Markdown ${this.state.inputState.isRichtextEnabled ? 'on' : 'off'}`}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown"
src={`img/button-md-${!this.state.inputState.isRichtextEnabled}.png`} />
<img title="Hide Text Formatting Toolbar"
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel"
src="img/icon-text-cancel.svg" />
</div>
</div>: null
}
</div> </div>
); );
} }

View file

@ -29,9 +29,11 @@ marked.setOptions({
import {Editor, EditorState, RichUtils, CompositeDecorator, import {Editor, EditorState, RichUtils, CompositeDecorator,
convertFromRaw, convertToRaw, Modifier, EditorChangeType, convertFromRaw, convertToRaw, Modifier, EditorChangeType,
getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js'; getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js';
import {stateToMarkdown} from 'draft-js-export-markdown'; import {stateToMarkdown} from 'draft-js-export-markdown';
import classNames from 'classnames';
import escape from 'lodash/escape';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@ -41,6 +43,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import KeyCode from '../../../KeyCode'; import KeyCode from '../../../KeyCode';
import UserSettingsStore from '../../../UserSettingsStore';
import * as RichText from '../../../RichText'; import * as RichText from '../../../RichText';
@ -80,7 +83,6 @@ export default class MessageComposerInput extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.onAction = this.onAction.bind(this); this.onAction = this.onAction.bind(this);
this.onInputClick = this.onInputClick.bind(this);
this.handleReturn = this.handleReturn.bind(this); this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.setEditorState = this.setEditorState.bind(this); this.setEditorState = this.setEditorState.bind(this);
@ -88,15 +90,12 @@ export default class MessageComposerInput extends React.Component {
this.onDownArrow = this.onDownArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this); this.onTab = this.onTab.bind(this);
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true);
if (isRichtextEnabled == null) {
isRichtextEnabled = 'true';
}
isRichtextEnabled = isRichtextEnabled === 'true';
this.state = { this.state = {
isRichtextEnabled: isRichtextEnabled, isRichtextEnabled,
editorState: null, editorState: null,
}; };
@ -236,8 +235,18 @@ export default class MessageComposerInput extends React.Component {
this.sentHistory.saveLastTextEntry(); this.sentHistory.saveLastTextEntry();
} }
componentWillUpdate(nextProps, nextState) {
// this is dirty, but moving all this state to MessageComposer is dirtier
if (this.props.onInputStateChanged && nextState !== this.state) {
const state = this.getSelectionInfo(nextState.editorState);
state.isRichtextEnabled = nextState.isRichtextEnabled;
this.props.onInputStateChanged(state);
}
}
onAction(payload) { onAction(payload) {
let editor = this.refs.editor; let editor = this.refs.editor;
let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) { switch (payload.action) {
case 'focus_composer': case 'focus_composer':
@ -246,35 +255,44 @@ export default class MessageComposerInput extends React.Component {
// TODO change this so we insert a complete user alias // TODO change this so we insert a complete user alias
case 'insert_displayname': case 'insert_displayname': {
if (this.state.editorState.getCurrentContent().hasText()) { contentState = Modifier.replaceText(
console.log(payload); contentState,
let contentState = Modifier.replaceText(
this.state.editorState.getCurrentContent(),
this.state.editorState.getSelection(), this.state.editorState.getSelection(),
payload.displayname `${payload.displayname}: `
); );
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);
editor.focus(); editor.focus();
} }
break; break;
}
case 'quote': {
let {event: {content: {body, formatted_body}}} = payload.event || {};
formatted_body = formatted_body || escape(body);
if (formatted_body) {
let content = RichText.HTMLtoContentState(`<blockquote>${formatted_body}</blockquote>`);
if (!this.state.isRichtextEnabled) {
content = ContentState.createFromText(stateToMarkdown(content));
} }
onKeyDown(ev) { const blockMap = content.getBlockMap();
if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) { let startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
var oldSelectionStart = this.refs.textarea.selectionStart; contentState = Modifier.splitBlock(contentState, startSelection);
// Remember the keyCode because React will recycle the synthetic event startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
var keyCode = ev.keyCode; contentState = Modifier.replaceWithFragment(contentState,
// set a callback so we can see if the cursor position changes as startSelection,
// a result of this event. If it doesn't, we cycle history. blockMap);
setTimeout(() => { startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
if (this.refs.textarea.selectionStart == oldSelectionStart) { if (this.state.isRichtextEnabled)
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1); contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
this.setEditorState(editorState);
editor.focus();
} }
}, 0); }
break;
} }
} }
@ -344,13 +362,10 @@ export default class MessageComposerInput extends React.Component {
} }
} }
onInputClick(ev) {
this.refs.editor.focus();
}
setEditorState(editorState: EditorState) { setEditorState(editorState: EditorState, cb = () => null) {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState); editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
this.setState({editorState}); this.setState({editorState}, cb);
if (editorState.getCurrentContent().hasText()) { if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity(); this.onTypingActivity();
@ -359,27 +374,34 @@ export default class MessageComposerInput extends React.Component {
} }
if (this.props.onContentChanged) { if (this.props.onContentChanged) {
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), const textContent = editorState.getCurrentContent().getPlainText();
RichText.selectionStateToTextOffsets(editorState.getSelection(), const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray())); editorState.getCurrentContent().getBlocksAsArray());
this.props.onContentChanged(textContent, selection);
} }
} }
enableRichtext(enabled: boolean) { enableRichtext(enabled: boolean) {
let contentState = null;
if (enabled) { if (enabled) {
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); const html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html))); contentState = RichText.HTMLtoContentState(html);
} else { } else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()), let markdown = stateToMarkdown(this.state.editorState.getCurrentContent());
if (markdown[markdown.length - 1] === '\n') {
markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?)
}
contentState = ContentState.createFromText(markdown); contentState = ContentState.createFromText(markdown);
this.setEditorState(this.createEditorState(enabled, contentState));
} }
window.localStorage.setItem('mx_editor_rte_enabled', enabled); this.setEditorState(this.createEditorState(enabled, contentState), () => {
this.setState({ this.setState({
isRichtextEnabled: enabled isRichtextEnabled: enabled,
}); });
});
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
} }
handleKeyCommand(command: string): boolean { handleKeyCommand(command: string): boolean {
@ -391,7 +413,17 @@ 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) {
// These are block types, not handled by RichUtils by default.
const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'];
if (blockCommands.includes(command)) {
this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command));
} else if (command === 'strike') {
// this is the only inline style not handled by Draft by default
this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'));
}
} else {
let contentState = this.state.editorState.getCurrentContent(), let contentState = this.state.editorState.getCurrentContent(),
selection = this.state.editorState.getSelection(); selection = this.state.editorState.getSelection();
@ -399,7 +431,11 @@ 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*
strike: text => `~~${text}~~`,
code: text => `\`${text}\``, code: text => `\`${text}\``,
blockquote: text => text.split('\n').map(line => `> ${line}\n`).join(''),
'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''),
'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''),
}[command]; }[command];
if (modifyFn) { if (modifyFn) {
@ -418,12 +454,14 @@ export default class MessageComposerInput extends React.Component {
this.setEditorState(newState); this.setEditorState(newState);
return true; return true;
} }
return false; return false;
} }
handleReturn(ev) { handleReturn(ev) {
if (ev.shiftKey) { if (ev.shiftKey) {
return false; this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState));
return true;
} }
const contentState = this.state.editorState.getCurrentContent(); const contentState = this.state.editorState.getCurrentContent();
@ -536,20 +574,91 @@ export default class MessageComposerInput extends React.Component {
setTimeout(() => this.refs.editor.focus(), 50); setTimeout(() => this.refs.editor.focus(), 50);
} }
render() { onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
let className = "mx_MessageComposer_input"; e.preventDefault(); // don't steal focus from the editor!
const command = {
if (this.state.isRichtextEnabled) { code: 'code-block',
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode quote: 'blockquote',
bullet: 'unordered-list-item',
numbullet: 'ordered-list-item',
}[name] || name;
this.handleKeyCommand(command);
} }
/* returns inline style and block type of current SelectionState so MessageComposer can render formatting
buttons. */
getSelectionInfo(editorState: EditorState) {
const styleName = {
BOLD: 'bold',
ITALIC: 'italic',
STRIKETHROUGH: 'strike',
UNDERLINE: 'underline',
};
const originalStyle = editorState.getCurrentInlineStyle().toArray();
const style = originalStyle
.map(style => styleName[style] || null)
.filter(styleName => !!styleName);
const blockName = {
'code-block': 'code',
blockquote: 'quote',
'unordered-list-item': 'bullet',
'ordered-list-item': 'numbullet',
};
const originalBlockType = editorState.getCurrentContent()
.getBlockForKey(editorState.getSelection().getStartKey())
.getType();
const blockType = blockName[originalBlockType] || null;
return {
style,
blockType,
};
}
onMarkdownToggleClicked(e) {
e.preventDefault(); // don't steal focus from the editor!
this.handleKeyCommand('toggle-mode');
}
getBlockStyle(block: ContentBlock): ?string {
if (block.getType() === 'strikethrough') {
return 'mx_Markdown_STRIKETHROUGH';
}
return null;
}
render() {
const {editorState} = this.state;
// From https://github.com/facebook/draft-js/blob/master/examples/rich/rich.html#L92
// If the user changes block type before entering any text, we can
// either style the placeholder or hide it.
let hidePlaceholder = false;
const contentState = editorState.getCurrentContent();
if (!contentState.hasText()) {
if (contentState.getBlockMap().first().getType() !== 'unstyled') {
hidePlaceholder = true;
}
}
const className = classNames('mx_MessageComposer_input', {
mx_MessageComposer_input_empty: hidePlaceholder,
});
return ( return (
<div className={className} <div className={className}>
onClick={ this.onInputClick }> <img className="mx_MessageComposer_input_markdownIndicator"
onMouseDown={this.onMarkdownToggleClicked}
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
<Editor ref="editor" <Editor ref="editor"
placeholder="Type a message…" placeholder="Type a message…"
editorState={this.state.editorState} editorState={this.state.editorState}
onChange={this.setEditorState} onChange={this.setEditorState}
blockStyleFn={this.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding} keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand} handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn} handleReturn={this.handleReturn}
@ -582,4 +691,6 @@ MessageComposerInput.propTypes = {
// attempts to confirm currently selected completion, returns whether actually confirmed // attempts to confirm currently selected completion, returns whether actually confirmed
tryComplete: React.PropTypes.func, tryComplete: React.PropTypes.func,
onInputStateChanged: React.PropTypes.func.isRequired,
}; };