rte improvements, markdown mode

This commit is contained in:
Aviral Dasgupta 2016-06-11 15:52:08 +05:30
parent bf8e56e04c
commit e4217c3fb7
3 changed files with 168 additions and 151 deletions

View file

@ -25,6 +25,8 @@
"classnames": "^2.1.2", "classnames": "^2.1.2",
"draft-js": "^0.7.0", "draft-js": "^0.7.0",
"draft-js-export-html": "^0.2.2", "draft-js-export-html": "^0.2.2",
"draft-js-export-markdown": "^0.2.0",
"draft-js-import-markdown": "^0.1.6",
"favico.js": "^0.3.10", "favico.js": "^0.3.10",
"filesize": "^3.1.2", "filesize": "^3.1.2",
"flux": "^2.0.3", "flux": "^2.0.3",

View file

@ -1,9 +1,8 @@
import {Editor, ContentState, convertFromHTML, DefaultDraftBlockRenderMap, DefaultDraftInlineStyle, CompositeDecorator} from 'draft-js'; import {Editor, ContentState, convertFromHTML, DefaultDraftBlockRenderMap, DefaultDraftInlineStyle, CompositeDecorator} from 'draft-js';
const ReactDOM = require('react-dom'); import * as sdk from './index';
var sdk = require('./index');
const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', {
element: 'p' // draft uses <div> by default which we don't really like element: 'p' // draft uses <div> by default which we don't really like, so we're using <p>
}); });
const styles = { const styles = {
@ -14,21 +13,19 @@ const styles = {
UNDERLINE: 'u' UNDERLINE: 'u'
}; };
export function contentStateToHTML(contentState:ContentState): String { export function contentStateToHTML(contentState: ContentState): string {
const elem = contentState.getBlockMap().map((block) => { return contentState.getBlockMap().map((block) => {
const elem = BLOCK_RENDER_MAP.get(block.getType()).element; let elem = BLOCK_RENDER_MAP.get(block.getType()).element;
const content = []; let content = [];
block.findStyleRanges(() => true, (s, e) => { block.findStyleRanges(() => true, (start, end) => {
const tags = block.getInlineStyleAt(s).map(style => styles[style]); const tags = block.getInlineStyleAt(start).map(style => styles[style]);
const open = tags.map(tag => `<${tag}>`).join(''); const open = tags.map(tag => `<${tag}>`).join('');
const close = tags.map(tag => `</${tag}>`).reverse().join(''); const close = tags.map(tag => `</${tag}>`).reverse().join('');
content.push(`${open}${block.getText().substring(s, e)}${close}`); content.push(`${open}${block.getText().substring(start, end)}${close}`);
}); });
return (`<${elem}>${content.join('')}</${elem}>`); return (`<${elem}>${content.join('')}</${elem}>`);
}).join(''); }).join('');
return elem;
} }
export function HTMLtoContentState(html:String): ContentState { export function HTMLtoContentState(html:String): ContentState {
@ -38,6 +35,12 @@ export function HTMLtoContentState(html:String): ContentState {
const USERNAME_REGEX = /@\S+:\S+/g; const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g;
/**
* Returns a composite decorator which has access to provided scope.
*
* @param scope
* @returns {*}
*/
export function getScopedDecorator(scope) { export function getScopedDecorator(scope) {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
@ -46,17 +49,13 @@ export function getScopedDecorator(scope) {
findWithRegex(USERNAME_REGEX, contentBlock, callback); findWithRegex(USERNAME_REGEX, contentBlock, callback);
}, },
component: (props) => { component: (props) => {
console.log(props.children); let member = scope.room.getMember(props.children[0].props.text);
console.log(props.children[0].props.text);
const member = scope.room.getMember(props.children[0].props.text);
console.log(scope);
window.scope = scope;
let name = null; let name = null;
if(!!member) { if(!!member) {
name = member.name; name = member.name;
} }
console.log(member); console.log(member);
const 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>;
} }
}; };

View file

@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require("react"); import React from 'react';
var marked = require("marked"); var marked = require("marked");
marked.setOptions({ marked.setOptions({
@ -27,7 +27,11 @@ marked.setOptions({
smartypants: false smartypants: false
}); });
import {Editor, EditorState, RichUtils, CompositeDecorator} from 'draft-js'; import {Editor, EditorState, RichUtils, CompositeDecorator,
convertFromRaw, convertToRaw, Modifier, EditorChangeType,
getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js';
import {stateToMarkdown} from 'draft-js-export-markdown';
var MatrixClientPeg = require("../../../MatrixClientPeg"); var MatrixClientPeg = require("../../../MatrixClientPeg");
var SlashCommands = require("../../../SlashCommands"); var SlashCommands = require("../../../SlashCommands");
@ -40,9 +44,20 @@ var KeyCode = require("../../../KeyCode");
import {contentStateToHTML, HTMLtoContentState, getScopedDecorator} from '../../../RichText'; import {contentStateToHTML, HTMLtoContentState, getScopedDecorator} from '../../../RichText';
var TYPING_USER_TIMEOUT = 10000; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
var TYPING_SERVER_TIMEOUT = 30000;
var MARKDOWN_ENABLED = true; function mdownToHtml(mdown) {
var html = marked(mdown) || "";
html = html.trim();
// strip start and end <p> tags else you get 'orrible spacing
if (html.indexOf("<p>") === 0) {
html = html.substring("<p>".length);
}
if (html.lastIndexOf("</p>") === (html.length - "</p>".length)) {
html = html.substring(0, html.length - "</p>".length);
}
return html;
}
/* /*
* The textInput part of the MessageComposer * The textInput part of the MessageComposer
@ -54,13 +69,38 @@ export default class MessageComposerInput extends React.Component {
this.onInputClick = this.onInputClick.bind(this); this.onInputClick = this.onInputClick.bind(this);
this.state = { this.state = {
editorState: EditorState.createEmpty(getScopedDecorator(this.props)) isRichtextEnabled: true,
editorState: null
}; };
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
this.state.editorState = this.createEditorState();
}
static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes
if(e.keyCode == 77 && 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
* - contentState was passed in
*/
createEditorState(contentState: ?ContentState): EditorState {
let func = contentState ? EditorState.createWithContent : EditorState.createEmpty;
let args = contentState ? [contentState] : [];
if(this.state.isRichtextEnabled) {
args.push(getScopedDecorator(this.props));
}
return func.apply(null, args);
} }
componentWillMount() { componentWillMount() {
this.oldScrollHeight = 0;
this.markdownEnabled = MARKDOWN_ENABLED;
const component = this; const component = this;
this.sentHistory = { this.sentHistory = {
// The list of typed messages. Index 0 is more recent // The list of typed messages. Index 0 is more recent
@ -132,7 +172,6 @@ export default class MessageComposerInput extends React.Component {
this.element.value = this.originalText; this.element.value = this.originalText;
} }
component.resizeInput();
return true; return true;
}, },
@ -140,18 +179,17 @@ export default class MessageComposerInput extends React.Component {
// save the currently entered text in order to restore it later. // save the currently entered text in order to restore it later.
// NB: This isn't 'originalText' because we want to restore // NB: This isn't 'originalText' because we want to restore
// sent history items too! // sent history items too!
const contentHTML = contentStateToHTML(component.state.editorState.getCurrentContent()); let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent()));
console.error(contentHTML); window.sessionStorage.setItem("input_" + this.roomId, contentJSON);
window.sessionStorage.setItem("input_" + this.roomId, contentHTML);
}, },
setLastTextEntry: function () { setLastTextEntry: function () {
const contentHTML = window.sessionStorage.getItem("input_" + this.roomId); let contentJSON = window.sessionStorage.getItem("input_" + this.roomId);
console.error(contentHTML); if (contentJSON) {
if (contentHTML) { let content = convertFromRaw(JSON.parse(contentJSON));
const content = HTMLtoContentState(contentHTML); component.setState({
component.setState({editorState: EditorState.createWithContent(content, getScopedDecorator(component.props))}); editorState: component.createEditorState(content)
component.resizeInput(); });
} }
} }
}; };
@ -163,10 +201,10 @@ export default class MessageComposerInput extends React.Component {
this.refs.editor, this.refs.editor,
this.props.room.roomId this.props.room.roomId
); );
this.resizeInput(); // this is disabled for now, since https://github.com/matrix-org/matrix-react-sdk/pull/296 will land soon
if (this.props.tabComplete) { // if (this.props.tabComplete) {
this.props.tabComplete.setTextArea(this.refs.editor); // this.props.tabComplete.setEditor(this.refs.editor);
} // }
} }
componentWillUnmount() { componentWillUnmount() {
@ -176,44 +214,33 @@ export default class MessageComposerInput extends React.Component {
onAction(payload) { onAction(payload) {
var editor = this.refs.editor; var editor = this.refs.editor;
switch (payload.action) { switch (payload.action) {
case 'focus_composer': case 'focus_composer':
editor.focus(); editor.focus();
break; break;
// TODO change this so we insert a complete user alias
case 'insert_displayname': case 'insert_displayname':
console.error('fixme'); if (this.state.editorState.getCurrentContent().hasText()) {
if (textarea.value.length) { console.log(payload);
var left = textarea.value.substring(0, textarea.selectionStart); let contentState = Modifier.replaceText(
var right = textarea.value.substring(textarea.selectionEnd); this.state.editorState.getCurrentContent(),
if (right.length) { this.state.editorState.getSelection(),
left += payload.displayname; payload.displayname
} );
else { this.setState({
left = left.replace(/( ?)$/, " " + payload.displayname); editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters')
} });
textarea.value = left + right; editor.focus();
textarea.focus();
textarea.setSelectionRange(left.length, left.length);
}
else {
textarea.value = payload.displayname + ": ";
textarea.focus();
} }
break; break;
} }
} }
onKeyDown(ev) { onKeyDown(ev) {
if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) { if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var input = this.refs.textarea.value;
if (input.length === 0) {
ev.preventDefault();
return;
}
this.sentHistory.push(input);
this.onEnter(ev);
}
else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var oldSelectionStart = this.refs.textarea.selectionStart; var oldSelectionStart = this.refs.textarea.selectionStart;
// Remember the keyCode because React will recycle the synthetic event // Remember the keyCode because React will recycle the synthetic event
var keyCode = ev.keyCode; var keyCode = ev.keyCode;
@ -222,48 +249,9 @@ export default class MessageComposerInput extends React.Component {
setTimeout(() => { setTimeout(() => {
if (this.refs.textarea.selectionStart == oldSelectionStart) { if (this.refs.textarea.selectionStart == oldSelectionStart) {
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1); this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
this.resizeInput();
} }
}, 0); }, 0);
} }
if (this.props.tabComplete) {
this.props.tabComplete.onKeyDown(ev);
}
var self = this;
setTimeout(function() {
if (self.refs.textarea && self.refs.textarea.value != '') {
self.onTypingActivity();
} else {
self.onFinishedTyping();
}
}, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :(
}
resizeInput() {
console.error('fixme');
// scrollHeight is at least equal to clientHeight, so we have to
// temporarily crimp clientHeight to 0 to get an accurate scrollHeight value
// this.refs.textarea.style.height = "20px"; // 20 hardcoded from CSS
// var newHeight = Math.min(this.refs.textarea.scrollHeight,
// this.constructor.MAX_HEIGHT);
// this.refs.textarea.style.height = Math.ceil(newHeight) + "px";
// this.oldScrollHeight = this.refs.textarea.scrollHeight;
//
// if (this.props.onResize) {
// // kick gemini-scrollbar to re-layout
// this.props.onResize();
// }
}
onKeyUp(ev) {
if (this.refs.textarea.scrollHeight !== this.oldScrollHeight ||
ev.keyCode === KeyCode.DELETE ||
ev.keyCode === KeyCode.BACKSPACE)
{
this.resizeInput();
}
} }
onEnter(ev) { onEnter(ev) {
@ -271,24 +259,24 @@ export default class MessageComposerInput extends React.Component {
// bodge for now to set markdown state on/off. We probably want a separate // bodge for now to set markdown state on/off. We probably want a separate
// area for "local" commands which don't hit out to the server. // area for "local" commands which don't hit out to the server.
if (contentText.indexOf("/markdown") === 0) { // if (contentText.indexOf("/markdown") === 0) {
ev.preventDefault(); // ev.preventDefault();
this.refs.textarea.value = ''; // this.refs.textarea.value = '';
if (contentText.indexOf("/markdown on") === 0) { // if (contentText.indexOf("/markdown on") === 0) {
this.markdownEnabled = true; // this.markdownEnabled = true;
} // }
else if (contentText.indexOf("/markdown off") === 0) { // else if (contentText.indexOf("/markdown off") === 0) {
this.markdownEnabled = false; // this.markdownEnabled = false;
} // }
else { // else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); // const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { // Modal.createDialog(ErrorDialog, {
title: "Unknown command", // title: "Unknown command",
description: "Usage: /markdown on|off" // description: "Usage: /markdown on|off"
}); // });
} // }
return; // return;
} // }
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
if (cmd) { if (cmd) {
@ -326,32 +314,23 @@ export default class MessageComposerInput extends React.Component {
contentText = contentText.substring(4); contentText = contentText.substring(4);
} }
else if (contentText[0] === '/') { else if (contentText[0] === '/') {
contentText = contentText.substring(1); contentText = contentText.substring(1);
} }
var htmlText; var htmlText;
if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) { if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) {
sendMessagePromise = isEmote ? sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) : MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) :
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
} }
else { else {
sendMessagePromise = isEmote ? sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
} }
sendMessagePromise.done(function() {
dis.dispatch({
action: 'message_sent'
});
}, function() {
dis.dispatch({
action: 'message_send_failed'
});
});
this.refs.textarea.value = ''; this.refs.textarea.value = '';
this.resizeInput();
ev.preventDefault(); ev.preventDefault();
} }
@ -436,7 +415,28 @@ export default class MessageComposerInput extends React.Component {
} }
handleKeyCommand(command) { handleKeyCommand(command) {
const newState = RichUtils.handleKeyCommand(this.state.editorState, command); if(command === 'toggle-mode') {
this.setState({
isRichtextEnabled: !this.state.isRichtextEnabled
});
if(!this.state.isRichtextEnabled) {
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
this.setState({
editorState: this.createEditorState(HTMLtoContentState(html))
});
} else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent());
let contentState = ContentState.createFromText(markdown);
this.setState({
editorState: this.createEditorState(contentState)
});
}
return true;
}
let newState = RichUtils.handleKeyCommand(this.state.editorState, command);
if (newState) { if (newState) {
this.onChange(newState); this.onChange(newState);
return true; return true;
@ -452,31 +452,50 @@ export default class MessageComposerInput extends React.Component {
if(!contentState.hasText()) if(!contentState.hasText())
return true; return true;
const contentText = contentState.getPlainText(), let contentText = contentState.getPlainText(), contentHTML;
contentHTML = contentStateToHTML(contentState);
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, contentHTML); if(this.state.isRichtextEnabled) {
contentHTML = contentStateToHTML(contentState);
} else {
contentHTML = mdownToHtml(contentText);
}
this.sentHistory.push(contentHTML);
let sendMessagePromise = MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, contentHTML);
sendMessagePromise.done(() => {
dis.dispatch({
action: 'message_sent'
});
}, () => {
dis.dispatch({
action: 'message_send_failed'
});
});
this.setState({ this.setState({
editorState: EditorState.createEmpty(getScopedDecorator(this.props)) editorState: this.createEditorState()
}); });
return true; return true;
} }
render() { render() {
const containerStyle = { let className = "mx_MessageComposer_input";
overflow: 'auto'
}; if(this.state.isRichtextEnabled) {
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
}
return ( return (
<div className="mx_MessageComposer_input" <div className={className}
onClick={ this.onInputClick } onClick={ this.onInputClick }>
style={containerStyle}>
<Editor ref="editor" <Editor ref="editor"
placeholder="Type a message…" placeholder="Type a message…"
editorState={this.state.editorState} editorState={this.state.editorState}
onChange={(state) => this.onChange(state)} onChange={(state) => this.onChange(state)}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={(command) => this.handleKeyCommand(command)} handleKeyCommand={(command) => this.handleKeyCommand(command)}
handleReturn={ev => this.handleReturn(ev)} /> handleReturn={ev => this.handleReturn(ev)} />
</div> </div>
@ -494,6 +513,3 @@ MessageComposerInput.propTypes = {
// js-sdk Room object // js-sdk Room object
room: React.PropTypes.object.isRequired room: React.PropTypes.object.isRequired
}; };
// the height we limit the composer to
MessageComposerInput.MAX_HEIGHT = 100;