RTE formatbar (wip)

Fixes vector-im/vector-web#2024
This commit is contained in:
Aviral Dasgupta 2016-09-04 21:03:40 +05:30
parent cd07907392
commit 71251293e4
4 changed files with 129 additions and 71 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,23 +14,7 @@ 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,
@ -42,36 +26,7 @@ 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 +53,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) => {
@ -178,19 +146,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

@ -42,6 +42,10 @@ export default class MessageComposer extends React.Component {
this.state = { this.state = {
autocompleteQuery: '', autocompleteQuery: '',
selection: null, 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({ this.setState({
autocompleteQuery: content, autocompleteQuery: content,
selection, 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() { 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 +216,12 @@ export default class MessageComposer extends React.Component {
</div> </div>
); );
const formattingButton = (
<img title="Text Formatting"
src="img/button-text-formatting.svg"
key="controls_formatting" />
);
controls.push( controls.push(
<MessageComposerInput <MessageComposerInput
ref={c => this.messageComposerInput = c} ref={c => this.messageComposerInput = c}
@ -218,6 +233,7 @@ export default class MessageComposer extends React.Component {
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} />,
formattingButton,
uploadButton, uploadButton,
hangupButton, hangupButton,
callButton, callButton,
@ -242,6 +258,17 @@ export default class MessageComposer extends React.Component {
</div>; </div>;
} }
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 <img className="mx_MessageComposer_format_button" title={name} onClick={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 +277,11 @@ export default class MessageComposer extends React.Component {
{controls} {controls}
</div> </div>
</div> </div>
{UserSettingsStore.isFeatureEnabled('rich_text_editor') ?
<div className="mx_MessageComposer_formatbar">
{formatButtons}
</div> : null
}
</div> </div>
); );
} }

View file

@ -32,6 +32,7 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js'; getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js';
import {stateToMarkdown} from 'draft-js-export-markdown'; import {stateToMarkdown} from 'draft-js-export-markdown';
import classNames from 'classnames';
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';
@ -359,9 +360,12 @@ 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());
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); this.setEditorState(newState);
return true; return true;
} }
return false; return false;
} }
@ -536,12 +541,79 @@ 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" | "quote" | "bullet" | "numbullet", e) {
let className = "mx_MessageComposer_input"; const style = {
bold: 'BOLD',
italic: 'ITALIC',
strike: 'STRIKETHROUGH',
}[name];
if (this.state.isRichtextEnabled) { if (style) {
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode 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 ( return (
<div className={className} <div className={className}