actually hook up RTE

pull/21833/head
Matthew Hodgson 2018-05-17 02:13:17 +01:00
parent ae208da805
commit e51554c626
2 changed files with 189 additions and 108 deletions

View File

@ -17,7 +17,7 @@ limitations under the License.
import { Value } from 'slate'; import { Value } from 'slate';
import Html from 'slate-html-serializer'; import Html from 'slate-html-serializer';
import { Markdown as Md } from 'slate-md-serializer'; import Md from 'slate-md-serializer';
import Plain from 'slate-plain-serializer'; import Plain from 'slate-plain-serializer';
import * as RichText from './RichText'; import * as RichText from './RichText';
import Markdown from './Markdown'; import Markdown from './Markdown';

View File

@ -23,7 +23,7 @@ import { Editor } from 'slate-react';
import { Value, Document, Event, Inline, Text, Range, Node } from 'slate'; import { Value, Document, Event, Inline, Text, Range, Node } from 'slate';
import Html from 'slate-html-serializer'; import Html from 'slate-html-serializer';
import { Markdown as Md } from 'slate-md-serializer'; import Md from 'slate-md-serializer';
import Plain from 'slate-plain-serializer'; import Plain from 'slate-plain-serializer';
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer"; import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer";
@ -107,43 +107,6 @@ export default class MessageComposerInput extends React.Component {
onInputStateChanged: PropTypes.func, onInputStateChanged: PropTypes.func,
}; };
/*
static getKeyBinding(ev: SyntheticKeyboardEvent): string {
// Restrict a subset of key bindings to ONLY having ctrl/meta* pressed and
// importantly NOT having alt, shift, meta/ctrl* pressed. draft-js does not
// handle this in `getDefaultKeyBinding` so we do it ourselves here.
//
// * if macOS, read second option
const ctrlCmdCommand = {
// C-m => Toggles between rich text and markdown modes
[KeyCode.KEY_M]: 'toggle-mode',
[KeyCode.KEY_B]: 'bold',
[KeyCode.KEY_I]: 'italic',
[KeyCode.KEY_U]: 'underline',
[KeyCode.KEY_J]: 'code',
[KeyCode.KEY_O]: 'split-block',
}[ev.keyCode];
if (ctrlCmdCommand) {
if (!isOnlyCtrlOrCmdKeyEvent(ev)) {
return null;
}
return ctrlCmdCommand;
}
// Handle keys such as return, left and right arrows etc.
return getDefaultKeyBinding(ev);
}
static getBlockStyle(block: ContentBlock): ?string {
if (block.getType() === 'strikethrough') {
return 'mx_Markdown_STRIKETHROUGH';
}
return null;
}
*/
client: MatrixClient; client: MatrixClient;
autocomplete: Autocomplete; autocomplete: Autocomplete;
historyManager: ComposerHistoryManager; historyManager: ComposerHistoryManager;
@ -181,6 +144,8 @@ export default class MessageComposerInput extends React.Component {
this.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' }); this.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' });
this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' }); this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' });
this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' }); this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' });
this.md = new Md();
this.html = new Html();
this.suppressAutoComplete = false; this.suppressAutoComplete = false;
this.direction = ''; this.direction = '';
@ -191,9 +156,9 @@ export default class MessageComposerInput extends React.Component {
* - whether we've got rich text mode enabled * - whether we've got rich text mode enabled
* - contentState was passed in * - contentState was passed in
*/ */
createEditorState(richText: boolean, value: ?Value): Value { createEditorState(richText: boolean, editorState: ?Value): Value {
if (value instanceof Value) { if (editorState instanceof Value) {
return value; return editorState;
} }
else { else {
// ...or create a new one. // ...or create a new one.
@ -275,7 +240,7 @@ export default class MessageComposerInput extends React.Component {
} }
} }
break; break;
*/ */
} }
}; };
@ -403,7 +368,7 @@ export default class MessageComposerInput extends React.Component {
} }
}); });
/* /*
const currentBlock = editorState.getSelection().getStartKey(); const currentBlock = editorState.getSelection().getStartKey();
const currentSelection = editorState.getSelection(); const currentSelection = editorState.getSelection();
const currentStartOffset = editorState.getSelection().getStartOffset(); const currentStartOffset = editorState.getSelection().getStartOffset();
@ -477,27 +442,54 @@ export default class MessageComposerInput extends React.Component {
// FIXME: this conversion should be handled in the store, surely // FIXME: this conversion should be handled in the store, surely
// i.e. "convert my current composer value into Rich or MD, as ComposerHistoryManager already does" // i.e. "convert my current composer value into Rich or MD, as ComposerHistoryManager already does"
let value = null; let editorState = null;
if (enabled) { if (enabled) {
// const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); // const sourceWithPills = this.plainWithMdPills.serialize(this.state.editorState);
// contentState = RichText.htmlToContentState(md.toHTML()); // const markdown = new Markdown(sourceWithPills);
// editorState = this.html.deserialize(markdown.toHTML());
value = Md.deserialize(Plain.serialize(this.state.editorState)); // we don't really want a custom MD parser hanging around, but the
// alternative would be:
editorState = this.md.deserialize(this.plainWithMdPills.serialize(this.state.editorState));
} else { } else {
// let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent()); // let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent());
// value = ContentState.createFromText(markdown); // value = ContentState.createFromText(markdown);
value = Plain.deserialize(Md.serialize(this.state.editorState)); editorState = Plain.deserialize(this.md.serialize(this.state.editorState));
} }
Analytics.setRichtextMode(enabled); Analytics.setRichtextMode(enabled);
this.setState({ this.setState({
editorState: this.createEditorState(enabled, value), editorState: this.createEditorState(enabled, editorState),
isRichtextEnabled: enabled, isRichtextEnabled: enabled,
}); });
SettingsStore.setValue("MessageComposerInput.isRichTextEnabled", null, SettingLevel.ACCOUNT, enabled); SettingsStore.setValue("MessageComposerInput.isRichTextEnabled", null, SettingLevel.ACCOUNT, enabled);
} };
/**
* Check if the current selection has a mark with `type` in it.
*
* @param {String} type
* @return {Boolean}
*/
hasMark = type => {
const { editorState } = this.state
return editorState.activeMarks.some(mark => mark.type == type)
};
/**
* Check if the any of the currently selected blocks are of `type`.
*
* @param {String} type
* @return {Boolean}
*/
hasBlock = type => {
const { editorState } = this.state
return editorState.blocks.some(node => node.type == type)
};
onKeyDown = (ev: Event, change: Change, editor: Editor) => { onKeyDown = (ev: Event, change: Change, editor: Editor) => {
@ -514,6 +506,22 @@ export default class MessageComposerInput extends React.Component {
this.direction = ''; this.direction = '';
} }
if (isOnlyCtrlOrCmdKeyEvent(ev)) {
const ctrlCmdCommand = {
// C-m => Toggles between rich text and markdown modes
[KeyCode.KEY_M]: 'toggle-mode',
[KeyCode.KEY_B]: 'bold',
[KeyCode.KEY_I]: 'italic',
[KeyCode.KEY_U]: 'underline',
[KeyCode.KEY_J]: 'code',
}[ev.keyCode];
if (ctrlCmdCommand) {
return this.handleKeyCommand(ctrlCmdCommand);
}
return false;
}
switch (ev.keyCode) { switch (ev.keyCode) {
case KeyCode.ENTER: case KeyCode.ENTER:
return this.handleReturn(ev); return this.handleReturn(ev);
@ -529,7 +537,7 @@ export default class MessageComposerInput extends React.Component {
// don't intercept it // don't intercept it
return; return;
} }
} };
handleKeyCommand = (command: string): boolean => { handleKeyCommand = (command: string): boolean => {
if (command === 'toggle-mode') { if (command === 'toggle-mode') {
@ -541,32 +549,79 @@ export default class MessageComposerInput extends React.Component {
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. // 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) {
/* const type = command;
// These are block types, not handled by RichUtils by default. const { editorState } = this.state;
const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item']; const change = editorState.change();
const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState); const { document } = editorState;
switch (type) {
// list-blocks:
case 'bulleted-list':
case 'numbered-list': {
// Handle the extra wrapping required for list buttons.
const isList = this.hasBlock('list-item');
const isType = editorState.blocks.some(block => {
return !!document.getClosest(block.key, parent => parent.type == type);
});
const shouldToggleBlockFormat = ( if (isList && isType) {
command === 'backspace' || change
command === 'split-block' .setBlocks(DEFAULT_NODE)
) && currentBlockType !== 'unstyled'; .unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list');
if (blockCommands.includes(command)) { } else if (isList) {
newState = RichUtils.toggleBlockType(this.state.editorState, command); change
} else if (command === 'strike') { .unwrapBlock(
// this is the only inline style not handled by Draft by default type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list'
newState = RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'); )
} else if (shouldToggleBlockFormat) { .wrapBlock(type);
const currentStartOffset = this.state.editorState.getSelection().getStartOffset(); } else {
const currentEndOffset = this.state.editorState.getSelection().getEndOffset(); change.setBlocks('list-item').wrapBlock(type);
if (currentStartOffset === 0 && currentEndOffset === 0) { }
// Toggle current block type (setting it to 'unstyled')
newState = RichUtils.toggleBlockType(this.state.editorState, currentBlockType);
} }
break;
// simple blocks
case 'paragraph':
case 'block-quote':
case 'heading-one':
case 'heading-two':
case 'heading-three':
case 'list-item':
case 'code-block': {
const isActive = this.hasBlock(type);
const isList = this.hasBlock('list-item');
if (isList) {
change
.setBlocks(isActive ? DEFAULT_NODE : type)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list');
} else {
change.setBlocks(isActive ? DEFAULT_NODE : type);
}
}
break;
// marks:
case 'bold':
case 'italic':
case 'code':
case 'underline':
case 'strikethrough': {
change.toggleMark(type);
}
break;
default:
console.warn(`ignoring unrecognised RTE command ${type}`);
return false;
} }
*/
this.onChange(change);
return true;
} else { } else {
/* /*
const contentState = this.state.editorState.getCurrentContent(); const contentState = this.state.editorState.getCurrentContent();
const multipleLinesSelected = RichText.hasMultiLineSelection(this.state.editorState); const multipleLinesSelected = RichText.hasMultiLineSelection(this.state.editorState);
@ -641,7 +696,8 @@ export default class MessageComposerInput extends React.Component {
this.setState({editorState: newState}); this.setState({editorState: newState});
return true; return true;
} }
*/ */
}
return false; return false;
}; };
/* /*
@ -671,19 +727,14 @@ export default class MessageComposerInput extends React.Component {
if (ev.shiftKey) { if (ev.shiftKey) {
return; return;
} }
/*
const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState); if (this.state.editorState.blocks.some(
if ( block => block in ['code-block', 'block-quote', 'bulleted-list', 'numbered-list']
['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'] )) {
.includes(currentBlockType) // allow the user to terminate blocks by hitting return rather than sending a msg
) { return;
// By returning false, we allow the default draft-js key binding to occur,
// which in this case invokes "split-block". This creates a new block of the
// same type, allowing the user to delete it with backspace.
// See handleKeyCommand (when command === 'backspace')
return false;
} }
*/
const editorState = this.state.editorState; const editorState = this.state.editorState;
let contentText; let contentText;
@ -989,6 +1040,17 @@ export default class MessageComposerInput extends React.Component {
await this.setDisplayedCompletion(null); // restore originalEditorState await this.setDisplayedCompletion(null); // restore originalEditorState
}; };
/* returns inline style and block type of current SelectionState so MessageComposer can render formatting
buttons. */
getSelectionInfo(editorState: Value) {
return {
marks: editorState.activeMarks,
// XXX: shouldn't we return all the types of blocks in the current selection,
// not just the anchor?
blockType: editorState.anchorBlock.type,
};
}
/* If passed null, restores the original editor content from state.originalEditorState. /* If passed null, restores the original editor content from state.originalEditorState.
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState. * If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
*/ */
@ -1059,9 +1121,24 @@ export default class MessageComposerInput extends React.Component {
const { attributes, children, node, isSelected } = props; const { attributes, children, node, isSelected } = props;
switch (node.type) { switch (node.type) {
case 'paragraph': { case 'paragraph':
return <p {...attributes}>{children}</p> return <p {...attributes}>{children}</p>;
} case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote>;
case 'bulleted-list':
return <ul {...attributes}>{children}</ul>;
case 'heading-one':
return <h1 {...attributes}>{children}</h1>;
case 'heading-two':
return <h2 {...attributes}>{children}</h2>;
case 'heading-three':
return <h3 {...attributes}>{children}</h3>;
case 'list-item':
return <li {...attributes}>{children}</li>;
case 'numbered-list':
return <ol {...attributes}>{children}</ol>;
case 'code-block':
return <p {...attributes}><code {...attributes}>{children}</code></p>;
case 'pill': { case 'pill': {
const { data } = node; const { data } = node;
const url = data.get('url'); const url = data.get('url');
@ -1106,29 +1183,35 @@ export default class MessageComposerInput extends React.Component {
} }
}; };
renderMark = props => {
const { children, mark, attributes } = props;
switch (mark.type) {
case 'bold':
return <strong {...{ attributes }}>{children}</strong>;
case 'italic':
return <em {...{ attributes }}>{children}</em>;
case 'code':
return <code {...{ attributes }}>{children}</code>;
case 'underline':
return <u {...{ attributes }}>{children}</u>;
case 'strikethrough':
return <del {...{ attributes }}>{children}</del>;
}
};
onFormatButtonClicked = (name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) => { onFormatButtonClicked = (name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) => {
e.preventDefault(); // don't steal focus from the editor! e.preventDefault(); // don't steal focus from the editor!
const command = { const command = {
code: 'code-block', code: 'code-block',
quote: 'blockquote', quote: 'block-quote',
bullet: 'unordered-list-item', bullet: 'bulleted-list',
numbullet: 'ordered-list-item', numbullet: 'numbered-list',
strike: 'strike-through',
}[name] || name; }[name] || name;
this.handleKeyCommand(command); this.handleKeyCommand(command);
}; };
/* returns inline style and block type of current SelectionState so MessageComposer can render formatting
buttons. */
getSelectionInfo(editorState: Value) {
return {
marks: editorState.activeMarks,
// XXX: shouldn't we return all the types of blocks in the current selection,
// not just the anchor?
blockType: editorState.anchorBlock.type,
};
}
getAutocompleteQuery(editorState: Value) { getAutocompleteQuery(editorState: Value) {
// We can just return the current block where the selection begins, which // We can just return the current block where the selection begins, which
// should be enough to capture any autocompletion input, given autocompletion // should be enough to capture any autocompletion input, given autocompletion
@ -1154,7 +1237,7 @@ export default class MessageComposerInput extends React.Component {
const range = { const range = {
beginning, // whether the selection is in the first block of the editor or not beginning, // whether the selection is in the first block of the editor or not
start: editorState.selection.anchorOffset, start: editorState.selection.anchorOffset,
end: (editorState.selection.anchorKey == editorState.selection.focusKey) ? end: (editorState.selection.anchorKey == editorState.selection.focusKey) ?
editorState.selection.focusOffset : editorState.selection.anchorOffset, editorState.selection.focusOffset : editorState.selection.anchorOffset,
} }
if (range.start > range.end) { if (range.start > range.end) {
@ -1203,11 +1286,9 @@ export default class MessageComposerInput extends React.Component {
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
renderNode={this.renderNode} renderNode={this.renderNode}
renderMark={this.renderMark}
spellCheck={true} spellCheck={true}
/* /*
blockStyleFn={MessageComposerInput.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handlePastedText={this.onTextPasted} handlePastedText={this.onTextPasted}
handlePastedFiles={this.props.onFilesPasted} handlePastedFiles={this.props.onFilesPasted}
stripPastedStyles={!this.state.isRichtextEnabled} stripPastedStyles={!this.state.isRichtextEnabled}