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": {
"browser-request": "^0.3.3",
"classnames": "^2.1.2",
"draft-js": "^0.7.0",
"draft-js-export-html": "^0.2.2",
"draft-js": "^0.8.1",
"draft-js-export-html": "^0.4.0",
"draft-js-export-markdown": "^0.2.0",
"draft-js-import-markdown": "^0.1.6",
"emojione": "2.2.3",
"favico.js": "^0.3.10",
"filesize": "^3.1.2",

View file

@ -14,64 +14,22 @@ import {
} from 'draft-js';
import * as sdk from './index';
import * as emojione from 'emojione';
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',
};
import {stateToHTML} from 'draft-js-export-html';
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\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 ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export function contentStateToHTML(contentState: ContentState): string {
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 const contentStateToHTML = stateToHTML;
export function HTMLtoContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html));
@ -98,6 +56,19 @@ function unicodeToEmojiUri(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
let emojiDecorator = {
strategy: (contentBlock, callback) => {
@ -151,7 +122,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
}
export function getScopedMDDecorators(scope: any): CompositeDecorator {
let markdownDecorators = ['BOLD', 'ITALIC'].map(
let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({
strategy: (contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
@ -178,19 +149,6 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
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.
*/

View file

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

View file

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

View file

@ -21,6 +21,7 @@ var Modal = require('../../../Modal');
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
import Autocomplete from './Autocomplete';
import classNames from 'classnames';
import UserSettingsStore from '../../../UserSettingsStore';
@ -38,10 +39,20 @@ export default class MessageComposer extends React.Component {
this.onDownArrow = this.onDownArrow.bind(this);
this._tryComplete = this._tryComplete.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 = {
autocompleteQuery: '',
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() {
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() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'};
@ -207,6 +237,16 @@ export default class MessageComposer extends React.Component {
</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(
<MessageComposerInput
ref={c => this.messageComposerInput = c}
@ -217,7 +257,9 @@ export default class MessageComposer extends React.Component {
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete
onContentChanged={this.onInputContentChanged} />,
onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />,
formattingButton,
uploadButton,
hangupButton,
callButton,
@ -242,6 +284,26 @@ export default class MessageComposer extends React.Component {
</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 (
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}>
{autoComplete}
@ -250,6 +312,22 @@ export default class MessageComposer extends React.Component {
{controls}
</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>
);
}

View file

@ -29,9 +29,11 @@ marked.setOptions({
import {Editor, EditorState, RichUtils, CompositeDecorator,
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 classNames from 'classnames';
import escape from 'lodash/escape';
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@ -41,6 +43,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import KeyCode from '../../../KeyCode';
import UserSettingsStore from '../../../UserSettingsStore';
import * as RichText from '../../../RichText';
@ -80,7 +83,6 @@ export default class MessageComposerInput extends React.Component {
constructor(props, context) {
super(props, context);
this.onAction = this.onAction.bind(this);
this.onInputClick = this.onInputClick.bind(this);
this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.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.onTab = this.onTab.bind(this);
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled');
if (isRichtextEnabled == null) {
isRichtextEnabled = 'true';
}
isRichtextEnabled = isRichtextEnabled === 'true';
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true);
this.state = {
isRichtextEnabled: isRichtextEnabled,
isRichtextEnabled,
editorState: null,
};
@ -236,8 +235,18 @@ export default class MessageComposerInput extends React.Component {
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) {
let editor = this.refs.editor;
let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) {
case 'focus_composer':
@ -246,35 +255,44 @@ export default class MessageComposerInput extends React.Component {
// TODO change this so we insert a complete user alias
case 'insert_displayname':
if (this.state.editorState.getCurrentContent().hasText()) {
console.log(payload);
let contentState = Modifier.replaceText(
this.state.editorState.getCurrentContent(),
case 'insert_displayname': {
contentState = Modifier.replaceText(
contentState,
this.state.editorState.getSelection(),
payload.displayname
`${payload.displayname}: `
);
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);
editor.focus();
}
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) {
if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var oldSelectionStart = this.refs.textarea.selectionStart;
// Remember the keyCode because React will recycle the synthetic event
var keyCode = ev.keyCode;
// set a callback so we can see if the cursor position changes as
// a result of this event. If it doesn't, we cycle history.
setTimeout(() => {
if (this.refs.textarea.selectionStart == oldSelectionStart) {
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
const blockMap = content.getBlockMap();
let startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
contentState = Modifier.splitBlock(contentState, startSelection);
startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
contentState = Modifier.replaceWithFragment(contentState,
startSelection,
blockMap);
startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
if (this.state.isRichtextEnabled)
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);
this.setState({editorState});
this.setState({editorState}, cb);
if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
@ -359,27 +374,34 @@ export default class MessageComposerInput extends React.Component {
}
if (this.props.onContentChanged) {
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(),
RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray()));
const textContent = editorState.getCurrentContent().getPlainText();
const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray());
this.props.onContentChanged(textContent, selection);
}
}
enableRichtext(enabled: boolean) {
let contentState = null;
if (enabled) {
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html)));
const html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
contentState = RichText.HTMLtoContentState(html);
} 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);
this.setEditorState(this.createEditorState(enabled, contentState));
}
window.localStorage.setItem('mx_editor_rte_enabled', enabled);
this.setEditorState(this.createEditorState(enabled, contentState), () => {
this.setState({
isRichtextEnabled: enabled
isRichtextEnabled: enabled,
});
});
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
}
handleKeyCommand(command: string): boolean {
@ -391,7 +413,17 @@ 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) {
// 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(),
selection = this.state.editorState.getSelection();
@ -399,7 +431,11 @@ 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*
strike: 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];
if (modifyFn) {
@ -418,12 +454,14 @@ export default class MessageComposerInput extends React.Component {
this.setEditorState(newState);
return true;
}
return false;
}
handleReturn(ev) {
if (ev.shiftKey) {
return false;
this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState));
return true;
}
const contentState = this.state.editorState.getCurrentContent();
@ -536,20 +574,91 @@ export default class MessageComposerInput extends React.Component {
setTimeout(() => this.refs.editor.focus(), 50);
}
render() {
let className = "mx_MessageComposer_input";
if (this.state.isRichtextEnabled) {
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
e.preventDefault(); // don't steal focus from the editor!
const command = {
code: 'code-block',
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 (
<div className={className}
onClick={ this.onInputClick }>
<div className={className}>
<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"
placeholder="Type a message…"
editorState={this.state.editorState}
onChange={this.setEditorState}
blockStyleFn={this.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
@ -582,4 +691,6 @@ MessageComposerInput.propTypes = {
// attempts to confirm currently selected completion, returns whether actually confirmed
tryComplete: React.PropTypes.func,
onInputStateChanged: React.PropTypes.func.isRequired,
};