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