diff --git a/package.json b/package.json index d1b9122a62..f8f0106a32 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/RichText.js b/src/RichText.js index 7cd78a14c9..073d873945 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -14,23 +14,7 @@ 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
by default which we don't really like, so we're using - this is probably not a good idea since 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, @@ -42,36 +26,7 @@ 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 => ``).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('')}`; - - // dirty hack because we don't want block level tags by default, but breaks - if (elem === 'span') - result += '
'; - return result; - }).join(''); -} +export const contentStateToHTML = stateToHTML; export function HTMLtoContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); @@ -98,6 +53,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) => { @@ -178,19 +146,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. */ diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 7b84d394e0..3d01052ccf 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -42,6 +42,10 @@ export default class MessageComposer extends React.Component { this.state = { autocompleteQuery: '', selection: null, + selectionInfo: { + style: [], + blockType: null, + }, }; } @@ -127,10 +131,11 @@ export default class MessageComposer extends React.Component { }); } - onInputContentChanged(content: string, selection: {start: number, end: number}) { + onInputContentChanged(content: string, selection: {start: number, end: number}, selectionInfo) { this.setState({ autocompleteQuery: content, selection, + selectionInfo, }); } @@ -155,6 +160,10 @@ export default class MessageComposer extends React.Component { } } + onFormatButtonClicked(name: "bold" | "italic" | "strike" | "quote" | "bullet" | "numbullet", event) { + this.messageComposerInput.onFormatButtonClicked(name, event); + } + render() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; @@ -207,6 +216,12 @@ export default class MessageComposer extends React.Component {
); + const formattingButton = ( + + ); + controls.push( this.messageComposerInput = c} @@ -218,6 +233,7 @@ export default class MessageComposer extends React.Component { onDownArrow={this.onDownArrow} tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete onContentChanged={this.onInputContentChanged} />, + formattingButton, uploadButton, hangupButton, callButton, @@ -242,6 +258,17 @@ export default class MessageComposer extends React.Component { ; } + + const {style, blockType} = this.state.selectionInfo; + const formatButtons = ["bold", "italic", "strike", "quote", "bullet", "numbullet"].map( + name => { + const active = style.includes(name) || blockType === name; + const suffix = active ? '-o-n' : ''; + const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); + return ; + }, + ); + return (
{autoComplete} @@ -250,6 +277,11 @@ export default class MessageComposer extends React.Component { {controls}
+ {UserSettingsStore.isFeatureEnabled('rich_text_editor') ? +
+ {formatButtons} +
: null + } ); } diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 2d42b65246..aebb1855f3 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -32,6 +32,7 @@ import {Editor, EditorState, RichUtils, CompositeDecorator, getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js'; import {stateToMarkdown} from 'draft-js-export-markdown'; +import classNames from 'classnames'; import MatrixClientPeg from '../../../MatrixClientPeg'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; @@ -359,9 +360,12 @@ 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()); + const selectionInfo = this.getSelectionInfo(editorState); + + this.props.onContentChanged(textContent, selection, selectionInfo); } } @@ -418,6 +422,7 @@ export default class MessageComposerInput extends React.Component { this.setEditorState(newState); return true; } + return false; } @@ -536,12 +541,79 @@ export default class MessageComposerInput extends React.Component { setTimeout(() => this.refs.editor.focus(), 50); } - render() { - let className = "mx_MessageComposer_input"; + onFormatButtonClicked(name: "bold" | "italic" | "strike" | "quote" | "bullet" | "numbullet", e) { + const style = { + bold: 'BOLD', + italic: 'ITALIC', + strike: 'STRIKETHROUGH', + }[name]; - if (this.state.isRichtextEnabled) { - className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode + if (style) { + e.preventDefault(); + this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, style)); + } else { + const blockType = { + quote: 'blockquote', + bullet: 'unordered-list-item', + numbullet: 'ordered-list-item', + }[name]; + + if (blockType) { + e.preventDefault(); + this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, blockType)); + } else { + console.error(`Unknown formatting style "${name}", ignoring.`); + } } + } + + /* 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', + }; + + const originalStyle = editorState.getCurrentInlineStyle().toArray(); + const style = originalStyle + .map(style => styleName[style] || null) + .filter(styleName => !!styleName); + + const blockName = { + 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, + }; + } + + 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 (