From c1f51a76dd6cca53ec95640bf5e666e708df25a1 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Oct 2018 10:55:55 +0100 Subject: [PATCH 1/3] Update to new version of slate Lots of fixes here as a lot of the API has changed (eg. anchorKey / offsetKey are now anchor.key and offset.key, and collapseFocusToThing is moveFocusToThing). Also changes the ref to a function (sorry for lumping this into the same PR). Hopefully will fix https://github.com/vector-im/riot-web/issues/7105 --- package.json | 4 +- .../views/rooms/MessageComposerInput.js | 164 +++++++++++------- 2 files changed, 99 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index 72481f2e3c..34419b1473 100644 --- a/package.json +++ b/package.json @@ -88,10 +88,10 @@ "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", - "slate": "0.34.7", + "slate": "^0.41.2", "slate-html-serializer": "^0.6.1", "slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3", - "slate-react": "^0.12.4", + "slate-react": "^0.18.10", "text-encoding-utf-8": "^1.0.1", "url": "^0.11.0", "velocity-vector": "vector-im/velocity#059e3b2", diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index c726f86808..4b9d1218af 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -106,6 +106,17 @@ const MARK_TAGS = { s: 'deleted', // deprecated }; +const SLATE_SCHEMA = { + inlines: { + pill: { + isVoid: true, + }, + emoji: { + isVoid: true, + }, + }, +}; + function onSendMessageFailed(err, room) { // XXX: temporary logging to try to diagnose // https://github.com/vector-im/riot-web/issues/3148 @@ -116,10 +127,10 @@ function onSendMessageFailed(err, room) { } function rangeEquals(a: Range, b: Range): boolean { - return (a.anchorKey === b.anchorKey - && a.anchorOffset === b.anchorOffset - && a.focusKey === b.focusKey - && a.focusOffset === b.focusOffset + return (a.anchor.key === b.anchor.key + && a.anchor.offset === b.anchorOffset + && a.focus.key === b.focusKey + && a.focus.offset === b.focusOffset && a.isFocused === b.isFocused && a.isBackward === b.isBackward); } @@ -239,7 +250,6 @@ export default class MessageComposerInput extends React.Component { completion: el.innerText, completionId: m[1], }, - isVoid: true, } } else { @@ -345,8 +355,12 @@ export default class MessageComposerInput extends React.Component { dis.unregister(this.dispatcherRef); } + _collectEditor = (e) => { + this._editor = e; + } + onAction = (payload) => { - const editor = this.refs.editor; + const editor = this._editor; let editorState = this.state.editorState; switch (payload.action) { @@ -402,10 +416,14 @@ export default class MessageComposerInput extends React.Component { } // XXX: this is to bring back the focus in a sane place and add a paragraph after it - change = change.select({ - anchorKey: quote.key, - focusKey: quote.key, - }).collapseToEndOfBlock().insertBlock(Block.create(DEFAULT_NODE)).focus(); + change = change.select(Range.create({ + anchor: { + key: quote.key, + }, + focus: { + key: quote.key, + }, + })).collapseToEndOfBlock().insertBlock(Block.create(DEFAULT_NODE)).focus(); this.onChange(change); } else { @@ -497,15 +515,15 @@ export default class MessageComposerInput extends React.Component { if (this.direction !== '') { const focusedNode = editorState.focusInline || editorState.focusText; - if (focusedNode.isVoid) { + if (editorState.schema.isVoid(focusedNode)) { // XXX: does this work in RTL? const edge = this.direction === 'Previous' ? 'End' : 'Start'; - if (editorState.isCollapsed) { - change = change[`collapseTo${ edge }Of${ this.direction }Text`](); + if (editorState.selection.isCollapsed) { + change = change[`moveTo${ edge }Of${ this.direction }Text`](); } else { const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText; if (block) { - change = change[`moveFocusTo${ edge }Of`](block); + change = change[`moveFocusTo${ edge }OfNode`](block); } } editorState = change.value; @@ -522,7 +540,7 @@ export default class MessageComposerInput extends React.Component { this.autocomplete.hide(); } - if (!editorState.document.isEmpty) { + if (Plain.serialize(editorState) !== '') { this.onTypingActivity(); } else { this.onFinishedTyping(); @@ -543,10 +561,14 @@ export default class MessageComposerInput extends React.Component { const unicodeEmoji = shortnameToUnicode(EMOJI_UNICODE_TO_SHORTNAME[emojiUc]); const range = Range.create({ - anchorKey: editorState.selection.startKey, - anchorOffset: currentStartOffset - emojiMatch[1].length - 1, - focusKey: editorState.selection.startKey, - focusOffset: currentStartOffset - 1, + anchor: { + key: editorState.selection.startKey, + offset: currentStartOffset - emojiMatch[1].length - 1, + }, + focus: { + key: editorState.selection.startKey, + offset: currentStartOffset - 1, + }, }); change = change.insertTextAtRange(range, unicodeEmoji); editorState = change.value; @@ -560,15 +582,18 @@ export default class MessageComposerInput extends React.Component { let match; while ((match = EMOJI_REGEX.exec(node.text)) !== null) { const range = Range.create({ - anchorKey: node.key, - anchorOffset: match.index, - focusKey: node.key, - focusOffset: match.index + match[0].length, + anchor: { + key: node.key, + offset: match.index, + }, + focus: { + key: node.key, + offset: match.index + match[0].length, + }, }); const inline = Inline.create({ type: 'emoji', data: { emojiUnicode: match[0] }, - isVoid: true, }); change = change.insertInlineAtRange(range, inline); editorState = change.value; @@ -580,10 +605,10 @@ export default class MessageComposerInput extends React.Component { // emoji picker can leave the selection stuck in the emoji's // child text. This seems to happen due to selection getting // moved in the normalisation phase after calculating these changes - if (editorState.anchorKey && - editorState.document.getParent(editorState.anchorKey).type === 'emoji') + if (editorState.selection.anchor.key && + editorState.document.getParent(editorState.selection.anchor.key).type === 'emoji') { - change = change.collapseToStartOfNextText(); + change = change.moveToStartOfNextText(); editorState = change.value; } @@ -673,7 +698,7 @@ export default class MessageComposerInput extends React.Component { editorState: this.createEditorState(enabled, editorState), isRichTextEnabled: enabled, }, ()=>{ - this.refs.editor.focus(); + this._editor.focus(); }); SettingsStore.setValue("MessageComposerInput.isRichTextEnabled", null, SettingLevel.ACCOUNT, enabled); @@ -760,7 +785,9 @@ export default class MessageComposerInput extends React.Component { // drop a point in history so the user can undo a word // XXX: this seems nasty but adding to history manually seems a no-go ev.preventDefault(); - return change.setOperationFlag("skip", false).setOperationFlag("merge", false).insertText(ev.key); + return change.withoutMerging(() => { + change.insertText(ev.key); + }); }; onBackspace = (ev: KeyboardEvent, change: Change): Change => { @@ -771,23 +798,25 @@ export default class MessageComposerInput extends React.Component { const { editorState } = this.state; // Allow Ctrl/Cmd-Backspace when focus starts at the start of the composer (e.g select-all) - // for some reason if slate sees you Ctrl-backspace and your anchorOffset=0 it just resets your focus - if (!editorState.isCollapsed && editorState.anchorOffset === 0) { + // for some reason if slate sees you Ctrl-backspace and your anchor.offset=0 it just resets your focus + // XXX: Doing this now seems to put slate into a broken state, and it didn't appear to be doing + // what it claims to do on the old version of slate anyway... + /*if (!editorState.isCollapsed && editorState.selection.anchor.offset === 0) { return change.delete(); - } + }*/ if (this.state.isRichTextEnabled) { // let backspace exit lists const isList = this.hasBlock('list-item'); - if (isList && editorState.anchorOffset == 0) { + if (isList && editorState.selection.anchor.offset == 0) { change .setBlocks(DEFAULT_NODE) .unwrapBlock('bulleted-list') .unwrapBlock('numbered-list'); return change; } - else if (editorState.anchorOffset == 0 && editorState.isCollapsed) { + else if (editorState.selection.anchor.offset == 0 && editorState.isCollapsed) { // turn blocks back into paragraphs if ((this.hasBlock('block-quote') || this.hasBlock('heading1') || @@ -803,7 +832,7 @@ export default class MessageComposerInput extends React.Component { // remove paragraphs entirely if they're nested const parent = editorState.document.getParent(editorState.anchorBlock.key); - if (editorState.anchorOffset == 0 && + if (editorState.selection.anchor.offset == 0 && this.hasBlock('paragraph') && parent.nodes.size == 1 && parent.object !== 'document') @@ -942,8 +971,8 @@ export default class MessageComposerInput extends React.Component { const collapseAndOffsetSelection = (selection, offset) => { const key = selection.endKey(); return new Range({ - anchorKey: key, anchorOffset: offset, - focusKey: key, focusOffset: offset, + anchorKey: key, anchor.offset: offset, + focus.key: key, focus.offset: offset, }); }; @@ -1000,18 +1029,16 @@ export default class MessageComposerInput extends React.Component { .insertFragment(fragment.document); } else { // in MD mode we don't want the rich content pasted as the magic was annoying people so paste plain - return change - .setOperationFlag("skip", false) - .setOperationFlag("merge", false) - .insertText(transfer.text); + return change.withoutMerging(() => { + change.insertText(transfer.text); + }); } } case 'text': // don't skip/merge so that multiple consecutive pastes can be undone individually - return change - .setOperationFlag("skip", false) - .setOperationFlag("merge", false) - .insertText(transfer.text); + return change.withoutMerging(() => { + change.insertText(transfer.text); + }); } }; @@ -1066,7 +1093,7 @@ export default class MessageComposerInput extends React.Component { this.setState({ editorState: this.createEditorState(), }, ()=>{ - this.refs.editor.focus(); + this._editor.focus(); }); } if (cmd.promise) { @@ -1196,7 +1223,7 @@ export default class MessageComposerInput extends React.Component { this.setState({ editorState: this.createEditorState(), - }, ()=>{ this.refs.editor.focus() }); + }, ()=>{ this._editor.focus() }); return true; }; @@ -1216,9 +1243,9 @@ export default class MessageComposerInput extends React.Component { // and we must be at the edge of the document (up=start, down=end) if (up) { - if (!selection.isAtStartOf(document)) return; + if (!selection.anchor.isAtStartOfNode(document)) return; } else { - if (!selection.isAtEndOf(document)) return; + if (!selection.anchor.isAtEndOfNode(document)) return; } const selected = this.selectHistory(up); @@ -1275,7 +1302,7 @@ export default class MessageComposerInput extends React.Component { this.suppressAutoComplete = true; this.setState({ editorState }, ()=>{ - this.refs.editor.focus(); + this._editor.focus(); }); return true; }; @@ -1345,15 +1372,11 @@ export default class MessageComposerInput extends React.Component { inline = Inline.create({ type: 'pill', data: { completion, completionId, href }, - // we can't put text in here otherwise the editor tries to select it - isVoid: true, }); } else if (completion === '@room') { inline = Inline.create({ type: 'pill', data: { completion, completionId }, - // we can't put text in here otherwise the editor tries to select it - isVoid: true, }); } @@ -1361,8 +1384,9 @@ export default class MessageComposerInput extends React.Component { if (range) { const change = editorState.change() - .collapseToAnchor() - .moveOffsetsTo(range.start, range.end) + .moveToAnchor() + .moveAnchorTo(range.start) + .moveFocusTo(range.end) .focus(); editorState = change.value; } @@ -1433,6 +1457,7 @@ export default class MessageComposerInput extends React.Component { room={this.props.room} shouldShowPillAvatar={shouldShowPillAvatar} isSelected={isSelected} + {...attributes} />; } else if (Pill.isPillUrl(url)) { @@ -1441,12 +1466,14 @@ export default class MessageComposerInput extends React.Component { room={this.props.room} shouldShowPillAvatar={shouldShowPillAvatar} isSelected={isSelected} + {...attributes} />; } else { const { text } = node; return { text } + {...attributes} ; } } @@ -1458,7 +1485,9 @@ export default class MessageComposerInput extends React.Component { const className = classNames('mx_emojione', { mx_emojione_selected: isSelected }); - return {; + const style = {}; + if (props.selected) style.border = '1px solid blue'; + return {; } } }; @@ -1486,7 +1515,7 @@ export default class MessageComposerInput extends React.Component { // of focusing it doesn't then cancel the format button being pressed // FIXME: can we just tell handleKeyCommand's change to invoke .focus()? if (document.activeElement && document.activeElement.className !== 'mx_MessageComposer_editor') { - this.refs.editor.focus(); + this._editor.focus(); setTimeout(()=>{ this.handleKeyCommand(name); }, 500); // can't find any callback to hook this to. onFocus and onChange and willComponentUpdate fire too early. @@ -1503,8 +1532,8 @@ export default class MessageComposerInput extends React.Component { // This avoids us having to serialize the whole thing to plaintext and convert // selection offsets in & out of the plaintext domain. - if (editorState.selection.anchorKey) { - return editorState.document.getDescendant(editorState.selection.anchorKey).text; + if (editorState.selection.anchor.key) { + return editorState.document.getDescendant(editorState.selection.anchor.key).text; } else { return ''; @@ -1518,16 +1547,16 @@ export default class MessageComposerInput extends React.Component { const firstGrandChild = firstChild && firstChild.nodes.get(0); beginning = (firstChild && firstGrandChild && firstChild.object === 'block' && firstGrandChild.object === 'text' && - editorState.selection.anchorKey === firstGrandChild.key); + editorState.selection.anchor.key === firstGrandChild.key); // return a character range suitable for handing to an autocomplete provider. // the range is relative to the anchor of the current editor selection. // if the selection spans multiple blocks, then we collapse it for the calculation. const range = { beginning, // whether the selection is in the first block of the editor or not - start: editorState.selection.anchorOffset, - end: (editorState.selection.anchorKey == editorState.selection.focusKey) ? - editorState.selection.focusOffset : editorState.selection.anchorOffset, + start: editorState.selection.anchor.offset, + end: (editorState.selection.anchor.key == editorState.selection.focus.key) ? + editorState.selection.focus.offset : editorState.selection.anchor.offset, } if (range.start > range.end) { const tmp = range.start; @@ -1543,7 +1572,7 @@ export default class MessageComposerInput extends React.Component { }; focusComposer = () => { - this.refs.editor.focus(); + this._editor.focus(); }; render() { @@ -1553,7 +1582,7 @@ export default class MessageComposerInput extends React.Component { mx_MessageComposer_input_error: this.state.someCompletions === false, }); - const isEmpty = this.state.editorState.document.isEmpty; + const isEmpty = Plain.serialize(this.state.editorState) === ''; let {placeholder} = this.props; // XXX: workaround for placeholder being shown when there is a formatting block e.g blockquote but no text @@ -1579,7 +1608,7 @@ export default class MessageComposerInput extends React.Component { onMouseDown={this.onMarkdownToggleClicked} title={this.state.isRichTextEnabled ? _t("Markdown is disabled") : _t("Markdown is enabled")} src={`img/button-md-${!this.state.isRichTextEnabled}.png`} /> - From 4e1fabd14043998aa372623635a5f108010620df Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Oct 2018 16:05:14 +0100 Subject: [PATCH 2/3] Remove spurious ...atributes in the wrong place We already have it above --- src/components/views/rooms/MessageComposerInput.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 4b9d1218af..640b1b85f3 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -1473,7 +1473,6 @@ export default class MessageComposerInput extends React.Component { const { text } = node; return { text } - {...attributes} ; } } From 6f9d673b79df42636b85427d44cbf29e02caec44 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Oct 2018 17:35:40 +0100 Subject: [PATCH 3/3] Focus composer after closing room settings For some reason the slate update means the composer doesn't have the focus after closing the room settings, and the end to end tests pick this up! --- src/components/structures/RoomView.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index e9e46a2ff6..32121d6de5 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -195,6 +195,8 @@ module.exports = React.createClass({ editingRoomSettings: RoomViewStore.isEditingSettings(), }; + if (this.state.editingRoomSettings && !newState.editingRoomSettings) dis.dispatch({action: 'focus_composer'}); + // Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307 console.log( 'RVS update:',