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`} /> -