mirror of
https://github.com/element-hq/element-web
synced 2024-11-27 11:47:23 +03:00
parent
cd07907392
commit
71251293e4
4 changed files with 129 additions and 71 deletions
|
@ -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",
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Loading…
Reference in a new issue