From 6225038656e18a804e1cf6ff3141645fc33f2cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Est=C3=AAv=C3=A3o?= Date: Mon, 29 Jun 2020 19:31:57 +0100 Subject: [PATCH] Issue 331/mention support on @ keypress (#22119) * Add '@' button for inserting mentions on mobile * Move react-native-azted dependency to external dependencies section * Handle promise rejection. * Fix focus issue. * Add space after mention * Update selection onFocus. * Check for site capabilities for mentions support * Add the mention button inside a toolbar. * Use HOC for site capabilities. * Only include space after mention on iOS On Android we are discarding selections when we think they might get stripped by Aztec, so adding the space to the ends results in the selection values getting discarded. * Use onKeyDown instead of onEnter and onBackspace. * Intercept @ keypress to trigger mention UI. * Intercept @ keypress to trigger mention UI. * Trigger the UI for mentions only when there is a space before the @ * Put mentions behind the DEV flag. * Only trigger @ keypress on DEV builds. * Remove DEV flag * Only make mention keypress available when capabilities and editing menu are on. * Enable space after mention on Android * Remove DEV flag for toolbar mention button. * Bring changes from gb-mobile to the monorepo structure. * Check triggerKeyCodes on Android * Add newline to end of file * Update code to use keycodes. * Update GB main reference. * Update dependencies. Co-authored-by: Matt Chowning --- package-lock.json | 3 + .../AztecReactTextChangedEvent.kt | 34 ++++++++ .../ReactNativeAztec/ReactAztecManager.java | 11 +-- .../ios/RNTAztecView/RCTAztecView.swift | 56 ++++++++++-- .../ios/RNTAztecView/RCTAztecViewManager.m | 2 + packages/react-native-aztec/package.json | 3 + packages/react-native-aztec/src/AztecView.js | 66 ++++++++++++-- packages/react-native-editor/ios/Podfile.lock | 4 +- .../rich-text/src/component/index.native.js | 86 ++++++++++++++----- 9 files changed, 220 insertions(+), 45 deletions(-) create mode 100644 packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/AztecReactTextChangedEvent.kt diff --git a/package-lock.json b/package-lock.json index e4dbd8374a3ae7..4cfbfe2c250978 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11930,6 +11930,9 @@ }, "@wordpress/react-native-aztec": { "version": "file:packages/react-native-aztec", + "requires": { + "@wordpress/keycodes": "file:packages/keycodes" + }, "dependencies": { "prop-types": { "version": "15.6.0", diff --git a/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/AztecReactTextChangedEvent.kt b/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/AztecReactTextChangedEvent.kt new file mode 100644 index 00000000000000..1cf6afdf7b1f3b --- /dev/null +++ b/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/AztecReactTextChangedEvent.kt @@ -0,0 +1,34 @@ +package org.wordpress.mobile.ReactNativeAztec + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.RCTEventEmitter + +/** + * This event includes all data contained in [com.facebook.react.views.textinput.ReactTextChangedEvent], + * plus some extra info Gutenberg needs from Aztec. + */ +class AztecReactTextChangedEvent( + viewId: Int, + private val mText: String, + private val mEventCount: Int, + private val mMostRecentChar: Char? +) : Event(viewId) { + + override fun getEventName(): String = "topAztecChange" + + override fun dispatch(rctEventEmitter: RCTEventEmitter) { + rctEventEmitter.receiveEvent(viewTag, eventName, serializeEventData()) + } + + private fun serializeEventData(): WritableMap = + Arguments.createMap().apply { + putString("text", mText) + putInt("eventCount", mEventCount) + putInt("target", viewTag) + if (mMostRecentChar != null) { + putInt("keyCode", mMostRecentChar.toInt()) + } + } +} diff --git a/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java b/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java index 013b9794ea54ad..7c1ca216c60a76 100644 --- a/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java +++ b/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java @@ -139,7 +139,7 @@ public Map getExportedCustomBubblingEventTypeConstants() { MapBuilder.of( "bubbled", "onSubmitEditing", "captured", "onSubmitEditingCapture")))*/ .put( - "topChange", + "topAztecChange", MapBuilder.of( "phasedRegistrationNames", MapBuilder.of("bubbled", "onChange"))) @@ -623,13 +623,15 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { // the text (minus the Enter char itself). if (!mEditText.isEnterPressedUnderway()) { int currentEventCount = mEditText.incrementAndGetEventCounter(); + boolean singleCharacterHasBeenAdded = count - before == 1; // The event that contains the event counter and updates it must be sent first. // TODO: t7936714 merge these events mEventDispatcher.dispatchEvent( - new ReactTextChangedEvent( + new AztecReactTextChangedEvent( mEditText.getId(), mEditText.toHtml(mEditText.getText(), false), - currentEventCount)); + currentEventCount, + singleCharacterHasBeenAdded ? s.charAt(start + before) : null)); mEventDispatcher.dispatchEvent( new ReactTextInputEvent( @@ -657,8 +659,7 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { } @Override - public void afterTextChanged(Editable s) { - } + public void afterTextChanged(Editable s) {} } private class AztecContentSizeWatcher implements com.facebook.react.views.textinput.ContentSizeWatcher { diff --git a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift index 31910f5ff8f980..c2638f697fafba 100644 --- a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift +++ b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift @@ -6,6 +6,7 @@ import UIKit class RCTAztecView: Aztec.TextView { @objc var onBackspace: RCTBubblingEventBlock? = nil @objc var onChange: RCTBubblingEventBlock? = nil + @objc var onKeyDown: RCTBubblingEventBlock? = nil @objc var onEnter: RCTBubblingEventBlock? = nil @objc var onFocus: RCTBubblingEventBlock? = nil @objc var onBlur: RCTBubblingEventBlock? = nil @@ -14,6 +15,7 @@ class RCTAztecView: Aztec.TextView { @objc var onSelectionChange: RCTBubblingEventBlock? = nil @objc var minWidth: CGFloat = 0 @objc var maxWidth: CGFloat = 0 + @objc var triggerKeyCodes: NSArray? @objc var activeFormats: NSSet? = nil { didSet { @@ -304,7 +306,7 @@ class RCTAztecView: Aztec.TextView { // MARK: - Edits open override func insertText(_ text: String) { - guard !interceptEnter(text) else { + guard !interceptEnter(text), !interceptTriggersKeyCodes(text) else { return } @@ -342,12 +344,13 @@ class RCTAztecView: Aztec.TextView { } guard text == "\n", - let onEnter = onEnter else { + let onKeyDown = onKeyDown else { return false } - let caretData = packCaretDataForRN() - onEnter(caretData) + var eventData = packCaretDataForRN() + eventData = add(keyTrigger: "\r", to: eventData) + onKeyDown(eventData) return true } @@ -356,19 +359,45 @@ class RCTAztecView: Aztec.TextView { || (selectedRange.location == 0 && selectedRange.length == 0) || text.count == 1 // send backspace event when cleaning all characters || selectedRange == NSRange(location: 0, length: textStorage.length), // send backspace event when deleting all the text - let onBackspace = onBackspace else { + let onKeyDown = onKeyDown else { return false } var range = selectedRange if text.count == 1 { range = NSRange(location: 0, length: textStorage.length) } - let caretData = packCaretDataForRN(overrideRange: range) + var caretData = packCaretDataForRN(overrideRange: range) onSelectionChange?(caretData) - onBackspace(caretData) + let backSpaceKeyCode:UInt8 = 8 + caretData = add(keyCode: backSpaceKeyCode, to: caretData) + onKeyDown(caretData) return true } + private func interceptTriggersKeyCodes(_ text: String) -> Bool { + guard let keyCodes = triggerKeyCodes, + keyCodes.count > 0, + let onKeyDown = onKeyDown, + text.count == 1 + else { + return false + } + for value in keyCodes { + guard let keyString = value as? String, + let keyCode = keyString.first?.asciiValue, + text.contains(keyString) + else { + continue + } + + var eventData = [AnyHashable:Any]() + eventData = add(keyCode: keyCode, to: eventData) + onKeyDown(eventData) + return true + } + return false; + } + private func isNewLineBeforeSelectionAndNotEndOfContent() -> Bool { guard let currentLocation = text.indexFromLocation(selectedRange.location) else { return false @@ -429,6 +458,19 @@ class RCTAztecView: Aztec.TextView { return result } + func add(keyTrigger: String, to pack:[AnyHashable: Any]) -> [AnyHashable: Any] { + guard let keyCode = keyTrigger.first?.asciiValue else { + return pack + } + return add(keyCode: keyCode, to: pack) + } + + func add(keyCode: UInt8, to pack:[AnyHashable: Any]) -> [AnyHashable: Any] { + var result = pack + result["keyCode"] = keyCode + return result + } + // MARK: - RN Properties @objc diff --git a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecViewManager.m b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecViewManager.m index 1f9d4a6a8da1ea..55a1c9f538ef6c 100644 --- a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecViewManager.m +++ b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecViewManager.m @@ -7,6 +7,8 @@ @interface RCT_EXTERN_MODULE(RCTAztecViewManager, NSObject) RCT_EXPORT_VIEW_PROPERTY(onBackspace, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onEnter, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onKeyDown, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(triggerKeyCodes, NSArray) RCT_EXPORT_VIEW_PROPERTY(onFocus, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onBlur, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onPaste, RCTBubblingEventBlock) diff --git a/packages/react-native-aztec/package.json b/packages/react-native-aztec/package.json index 09708071575ef9..9b999ac61ac4e5 100644 --- a/packages/react-native-aztec/package.json +++ b/packages/react-native-aztec/package.json @@ -18,6 +18,9 @@ "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" }, + "dependencies": { + "@wordpress/keycodes": "file:../keycodes" + }, "peerDependencies": { "react": "*", "react-native": "*" diff --git a/packages/react-native-aztec/src/AztecView.js b/packages/react-native-aztec/src/AztecView.js index ed92c4efc1132e..4fceea432c6569 100644 --- a/packages/react-native-aztec/src/AztecView.js +++ b/packages/react-native-aztec/src/AztecView.js @@ -9,6 +9,10 @@ import ReactNative, { Platform, } from 'react-native'; import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; +/** + * WordPress dependencies + */ +import { ENTER, BACKSPACE } from '@wordpress/keycodes'; const AztecManager = UIManager.getViewManagerConfig( 'RCTAztecView' ); @@ -18,6 +22,8 @@ class AztecView extends React.Component { this._onContentSizeChange = this._onContentSizeChange.bind( this ); this._onEnter = this._onEnter.bind( this ); this._onBackspace = this._onBackspace.bind( this ); + this._onKeyDown = this._onKeyDown.bind( this ); + this._onChange = this._onChange.bind( this ); this._onHTMLContentWithCursor = this._onHTMLContentWithCursor.bind( this ); @@ -57,21 +63,43 @@ class AztecView extends React.Component { return; } - if ( ! this.props.onEnter ) { + if ( ! this.props.onKeyDown ) { return; } - const { onEnter } = this.props; - onEnter( event ); + const { onKeyDown } = this.props; + + const newEvent = { ...event, keyCode: ENTER }; + onKeyDown( newEvent ); } _onBackspace( event ) { - if ( ! this.props.onBackspace ) { + if ( ! this.props.onKeyDown ) { + return; + } + + const { onKeyDown } = this.props; + + const newEvent = { + ...event, + keyCode: BACKSPACE, + preventDefault: () => {}, + }; + onKeyDown( newEvent ); + } + + _onKeyDown( event ) { + if ( ! this.props.onKeyDown ) { return; } - const { onBackspace } = this.props; - onBackspace( event ); + const { onKeyDown } = this.props; + const newEvent = { + ...event, + keyCode: event.nativeEvent.keyCode, + preventDefault: () => {}, + }; + onKeyDown( newEvent ); } _onHTMLContentWithCursor( event ) { @@ -107,6 +135,26 @@ class AztecView extends React.Component { onBlur( event ); } + _onChange( event ) { + // iOS uses the the onKeyDown prop directly from native only when one of the triggerKeyCodes is entered, but + // Android includes the information needed for onKeyDown in the event passed to onChange. + if ( Platform.OS === 'android' ) { + const triggersIncludeEventKeyCode = + this.props.triggerKeyCodes && + this.props.triggerKeyCodes + .map( ( char ) => char.charCodeAt( 0 ) ) + .includes( event.nativeEvent.keyCode ); + if ( triggersIncludeEventKeyCode ) { + this._onKeyDown( event ); + } + } + + const { onChange } = this.props; + if ( onChange ) { + onChange( event ); + } + } + _onSelectionChange( event ) { if ( this.props.onSelectionChange ) { const { selectionStart, selectionEnd, text } = event.nativeEvent; @@ -182,15 +230,17 @@ class AztecView extends React.Component { style={ style } onContentSizeChange={ this._onContentSizeChange } onHTMLContentWithCursor={ this._onHTMLContentWithCursor } + onChange={ this._onChange } onSelectionChange={ this._onSelectionChange } - onEnter={ this.props.onEnter && this._onEnter } + onEnter={ this.props.onKeyDown && this._onEnter } + onBackspace={ this.props.onKeyDown && this._onBackspace } + onKeyDown={ this.props.onKeyDown && this._onKeyDown } deleteEnter={ this.props.deleteEnter } // IMPORTANT: the onFocus events are thrown away as these are handled by onPress() in the upper level. // It's necessary to do this otherwise onFocus may be set by `{...otherProps}` and thus the onPress + onFocus // combination generate an infinite loop as described in https://github.com/wordpress-mobile/gutenberg-mobile/issues/302 onFocus={ this._onAztecFocus } onBlur={ this._onBlur } - onBackspace={ this._onBackspace } /> ); diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index 3feb736566dd58..19cb2c000b158d 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -21,7 +21,7 @@ PODS: - DoubleConversion - glog - glog (0.3.5) - - Gutenberg (8.4.0-rc.1): + - Gutenberg (8.4.0): - React (= 0.61.5) - React-CoreModules (= 0.61.5) - React-RCTImage (= 0.61.5) @@ -372,7 +372,7 @@ SPEC CHECKSUMS: FBReactNativeSpec: 118d0d177724c2d67f08a59136eb29ef5943ec75 Folly: 30e7936e1c45c08d884aa59369ed951a8e68cf51 glog: 1f3da668190260b06b429bb211bfbee5cd790c28 - Gutenberg: f7055103da8a673d813ecec75875d973e3d25445 + Gutenberg: 42a3ed491af07194744d45aa7fc44b8202ea1a5b RCTRequired: b153add4da6e7dbc44aebf93f3cf4fcae392ddf1 RCTTypeSafety: 9aa1b91d7f9310fc6eadc3cf95126ffe818af320 React: b6a59ef847b2b40bb6e0180a97d0ca716969ac78 diff --git a/packages/rich-text/src/component/index.native.js b/packages/rich-text/src/component/index.native.js index b3dfc7e1c482a6..887aa19647a49b 100644 --- a/packages/rich-text/src/component/index.native.js +++ b/packages/rich-text/src/component/index.native.js @@ -27,7 +27,7 @@ import { compose, withPreferredColorScheme } from '@wordpress/compose'; import { withSelect } from '@wordpress/data'; import { childrenBlock } from '@wordpress/blocks'; import { decodeEntities } from '@wordpress/html-entities'; -import { BACKSPACE } from '@wordpress/keycodes'; +import { BACKSPACE, DELETE, ENTER } from '@wordpress/keycodes'; import { isURL } from '@wordpress/url'; import { Icon, atSymbol } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; @@ -40,6 +40,7 @@ import { applyFormat } from '../apply-format'; import { getActiveFormat } from '../get-active-format'; import { getActiveFormats } from '../get-active-formats'; import { insert } from '../insert'; +import { getTextContent } from '../get-text-content'; import { isEmpty, isEmptyLine } from '../is-empty'; import { create } from '../create'; import { toHTMLString } from '../to-html-string'; @@ -80,8 +81,10 @@ export class RichText extends Component { this.isIOS = Platform.OS === 'ios'; this.createRecord = this.createRecord.bind( this ); this.onChange = this.onChange.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); this.handleEnter = this.handleEnter.bind( this ); this.handleDelete = this.handleDelete.bind( this ); + this.handleMention = this.handleMention.bind( this ); this.onPaste = this.onPaste.bind( this ); this.onFocus = this.onFocus.bind( this ); this.onBlur = this.onBlur.bind( this ); @@ -99,6 +102,7 @@ export class RichText extends Component { ); this.valueToFormat = this.valueToFormat.bind( this ); this.getHtmlToRender = this.getHtmlToRender.bind( this ); + this.showMention = this.showMention.bind( this ); this.insertString = this.insertString.bind( this ); this.state = { activeFormats: [], @@ -297,7 +301,20 @@ export class RichText extends Component { this.lastAztecEventType = 'content size change'; } + onKeyDown( event ) { + if ( event.defaultPrevented ) { + return; + } + + this.handleDelete( event ); + this.handleEnter( event ); + this.handleMention( event ); + } + handleEnter( event ) { + if ( event.keyCode !== ENTER ) { + return; + } const { onEnter } = this.props; if ( ! onEnter ) { @@ -313,7 +330,11 @@ export class RichText extends Component { } handleDelete( event ) { - const keyCode = BACKSPACE; // TODO : should we differentiate BACKSPACE and DELETE? + const { keyCode } = event; + + if ( keyCode !== DELETE && keyCode !== BACKSPACE ) { + return; + } const isReverse = keyCode === BACKSPACE; const { onDelete, __unstableMultilineTag: multilineTag } = this.props; @@ -366,6 +387,35 @@ export class RichText extends Component { this.lastAztecEventType = 'input'; } + handleMention( event ) { + const { keyCode } = event; + + if ( keyCode !== '@'.charCodeAt( 0 ) ) { + return; + } + const record = this.getRecord(); + const text = getTextContent( record ); + // Only start the mention UI if the selection is on the start of text or the character before is a space + if ( + text.length === 0 || + record.start === 0 || + text.charAt( record.start - 1 ) === ' ' + ) { + this.showMention(); + } else { + this.insertString( record, '@' ); + } + } + + showMention() { + const record = this.getRecord(); + addMention() + .then( ( mentionUserId ) => { + this.insertString( record, `@${ mentionUserId } ` ); + } ) + .catch( () => {} ); + } + /** * Handles a paste event from the native Aztec Wrapper. * @@ -690,6 +740,7 @@ export class RichText extends Component { parentBlockStyles, withoutInteractiveFormatting, capabilities, + disableEditingMenu = false, } = this.props; const record = this.getRecord(); @@ -797,8 +848,13 @@ export class RichText extends Component { onChange={ this.onChange } onFocus={ this.onFocus } onBlur={ this.onBlur } - onEnter={ this.handleEnter } - onBackspace={ this.handleDelete } + onKeyDown={ this.onKeyDown } + triggerKeyCodes={ + disableEditingMenu === false && + isMentionsSupported( capabilities ) + ? [ '@' ] + : [] + } onPaste={ this.onPaste } activeFormats={ this.getActiveFormatNames( record ) } onContentSizeChange={ this.onContentSizeChange } @@ -820,7 +876,7 @@ export class RichText extends Component { } fontWeight={ this.props.fontWeight } fontStyle={ this.props.fontStyle } - disableEditingMenu={ this.props.disableEditingMenu } + disableEditingMenu={ disableEditingMenu } isMultiline={ this.isMultiline } textAlign={ this.props.textAlign } { ...( this.isIOS ? { maxWidth } : {} ) } @@ -842,28 +898,12 @@ export class RichText extends Component { { // eslint-disable-next-line no-undef - __DEV__ && isMentionsSupported( capabilities ) && ( + isMentionsSupported( capabilities ) && ( } - onClick={ () => { - addMention() - .then( - ( mentionUserId ) => { - let stringToInsert = `@${ mentionUserId }`; - if ( this.isIOS ) { - stringToInsert += - ' '; - } - this.insertString( - record, - stringToInsert - ); - } - ) - .catch( () => {} ); - } } + onClick={ this.showMention } /> )