diff --git a/packages/block-library/src/heading/edit.native.js b/packages/block-library/src/heading/edit.native.js
index 44289a550ce0b0..ab578dfd22916c 100644
--- a/packages/block-library/src/heading/edit.native.js
+++ b/packages/block-library/src/heading/edit.native.js
@@ -14,7 +14,7 @@ import { View } from 'react-native';
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { RichText, BlockControls } from '@wordpress/editor';
-import { parse, createBlock } from '@wordpress/blocks';
+import { createBlock } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -62,14 +62,7 @@ class HeadingEdit extends Component {
style={ {
minHeight: Math.max( minHeight, this.state.aztecHeight ),
} }
- onChange={ ( event ) => {
- // Create a React Tree from the new HTML
- const newParaBlock = parse( `<${ tagName }>${ event.content }${ tagName }>` )[ 0 ];
- setAttributes( {
- ...this.props.attributes,
- content: newParaBlock.attributes.content,
- } );
- } }
+ onChange={ ( value ) => setAttributes( { content: value } ) }
onMerge={ mergeBlocks }
onSplit={
insertBlocksAfter ?
diff --git a/packages/block-library/src/paragraph/edit.native.js b/packages/block-library/src/paragraph/edit.native.js
index 3ed4225f7b26d5..b8205240d48d1b 100644
--- a/packages/block-library/src/paragraph/edit.native.js
+++ b/packages/block-library/src/paragraph/edit.native.js
@@ -8,7 +8,7 @@ import { View } from 'react-native';
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
-import { parse, createBlock } from '@wordpress/blocks';
+import { createBlock } from '@wordpress/blocks';
import { RichText } from '@wordpress/editor';
/**
@@ -99,12 +99,9 @@ class ParagraphEdit extends Component {
...style,
minHeight: Math.max( minHeight, this.state.aztecHeight ),
} }
- onChange={ ( event ) => {
- // Create a React Tree from the new HTML
- const newParaBlock = parse( '
' + event.content + '
' )[ 0 ];
+ onChange={ ( nextContent ) => {
setAttributes( {
- ...this.props.attributes,
- content: newParaBlock.attributes.content,
+ content: nextContent,
} );
} }
onSplit={ this.splitBlock }
diff --git a/packages/editor/src/components/index.native.js b/packages/editor/src/components/index.native.js
index fc41a93fa6af31..5581015fa1d16b 100644
--- a/packages/editor/src/components/index.native.js
+++ b/packages/editor/src/components/index.native.js
@@ -1,7 +1,11 @@
export * from './colors';
export * from './font-sizes';
export { default as PlainText } from './plain-text';
-export { default as RichText } from './rich-text';
+export {
+ default as RichText,
+ RichTextShortcut,
+ RichTextToolbarButton,
+} from './rich-text';
export { default as MediaPlaceholder } from './media-placeholder';
export { default as BlockFormatControls } from './block-format-controls';
export { default as BlockControls } from './block-controls';
@@ -13,3 +17,4 @@ export { default as EditorHistoryUndo } from './editor-history/undo';
export { default as InspectorControls } from './inspector-controls';
export { default as BottomSheet } from './mobile/bottom-sheet';
export { default as Picker } from './mobile/picker';
+export { default as URLInput } from './url-input';
diff --git a/packages/editor/src/components/rich-text/format-toolbar/index.native.js b/packages/editor/src/components/rich-text/format-toolbar/index.native.js
new file mode 100644
index 00000000000000..d90860c05f4d67
--- /dev/null
+++ b/packages/editor/src/components/rich-text/format-toolbar/index.native.js
@@ -0,0 +1,18 @@
+/**
+ * WordPress dependencies
+ */
+
+import { Toolbar, Slot } from '@wordpress/components';
+
+const FormatToolbar = ( { controls } ) => {
+ return (
+
+ { controls.map( ( format ) =>
+
+ ) }
+
+
+ );
+};
+
+export default FormatToolbar;
diff --git a/packages/editor/src/components/rich-text/index.native.js b/packages/editor/src/components/rich-text/index.native.js
index 72ff2d49c5b184..72e7c70a0a3003 100644
--- a/packages/editor/src/components/rich-text/index.native.js
+++ b/packages/editor/src/components/rich-text/index.native.js
@@ -3,19 +3,16 @@
*/
import RCTAztecView from 'react-native-aztec';
import { View, Platform } from 'react-native';
-import {
- forEach,
- merge,
-} from 'lodash';
/**
* WordPress dependencies
*/
import { Component, RawHTML } from '@wordpress/element';
import { withInstanceId, compose } from '@wordpress/compose';
-import { Toolbar } from '@wordpress/components';
import { BlockFormatControls } from '@wordpress/editor';
+import { withSelect } from '@wordpress/data';
import {
+ getActiveFormat,
isEmpty,
create,
split,
@@ -23,47 +20,28 @@ import {
} from '@wordpress/rich-text';
import { BACKSPACE } from '@wordpress/keycodes';
import { children } from '@wordpress/blocks';
-import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
-import styles from './style.scss';
+import FormatEdit from './format-edit';
+import FormatToolbar from './format-toolbar';
-const FORMATTING_CONTROLS = [
- {
- icon: 'editor-bold',
- title: __( 'Bold' ),
- format: 'bold',
- },
- {
- icon: 'editor-italic',
- title: __( 'Italic' ),
- format: 'italic',
- },
- // TODO: get this back after alpha
- // {
- // icon: 'admin-links',
- // title: __( 'Link' ),
- // format: 'link',
- // },
- {
- icon: 'editor-strikethrough',
- title: __( 'Strikethrough' ),
- format: 'strikethrough',
- },
-];
+import styles from './style.scss';
const isRichTextValueEmpty = ( value ) => {
return ! value || ! value.length;
};
-export function getFormatValue( formatName ) {
- if ( 'link' === formatName ) {
- //TODO: Implement link command
- }
- return { isActive: true };
-}
+const unescapeSpaces = ( text ) => {
+ return text.replace( / | /gi, ' ' );
+};
+
+const gutenbergFormatNamesToAztec = {
+ 'core/bold': 'bold',
+ 'core/italic': 'italic',
+ 'core/strikethrough': 'strikethrough',
+};
export class RichText extends Component {
constructor() {
@@ -73,17 +51,31 @@ export class RichText extends Component {
this.onEnter = this.onEnter.bind( this );
this.onBackspace = this.onBackspace.bind( this );
this.onContentSizeChange = this.onContentSizeChange.bind( this );
- this.changeFormats = this.changeFormats.bind( this );
- this.toggleFormat = this.toggleFormat.bind( this );
- this.onActiveFormatsChange = this.onActiveFormatsChange.bind( this );
- this.isEmpty = this.isEmpty.bind( this );
+ this.onFormatChange = this.onFormatChange.bind( this );
+ // This prevents a bug in Aztec which triggers onSelectionChange twice on format change
+ this.onSelectionChange = this.onSelectionChange.bind( this );
this.valueToFormat = this.valueToFormat.bind( this );
this.state = {
- formats: {},
- selectedNodeId: 0,
+ start: 0,
+ end: 0,
+ formatPlaceholder: null,
};
}
+ /**
+ * Get the current record (value and selection) from props and state.
+ *
+ * @return {Object} The current record (value and selection).
+ */
+ getRecord() {
+ const { formatPlaceholder, start, end } = this.state;
+ // Since we get the text selection from Aztec we need to be in sync with the HTML `value`
+ // Removing leading white spaces using `trim()` should make sure this is the case.
+ const { formats, text } = this.formatToValue( this.props.value === undefined ? undefined : this.props.value.trimLeft() );
+
+ return { formats, formatPlaceholder, text, start, end };
+ }
+
/*
* Splits the content at the location of the selection.
*
@@ -149,19 +141,37 @@ export class RichText extends Component {
return this.removeRootTagsProduceByAztec( value );
}
- onActiveFormatsChange( formats ) {
- // force re-render the component skipping shouldComponentUpdate() See: https://reactjs.org/docs/react-component.html#forceupdate
- // This is needed because our shouldComponentUpdate impl. doesn't take in consideration props yet.
- this.forceUpdate();
- const newFormats = formats.reduce( ( accFormats, activeFormat ) => {
- accFormats[ activeFormat ] = getFormatValue( activeFormat );
- return accFormats;
- }, {} );
+ getActiveFormatNames( record ) {
+ const {
+ formatTypes,
+ } = this.props;
+ return formatTypes.map( ( { name } ) => name ).filter( ( name ) => {
+ return getActiveFormat( record, name ) !== undefined;
+ } ).map( ( name ) => gutenbergFormatNamesToAztec[ name ] ).filter( Boolean );
+ }
+
+ onFormatChange( record ) {
+ let newContent;
+ // valueToFormat might throw when converting the record to a tree structure
+ // let's ignore the event for now and force a render update so we're still in sync
+ try {
+ newContent = this.valueToFormat( record );
+ } catch ( error ) {
+ // eslint-disable-next-line no-console
+ console.log( error );
+ }
this.setState( {
- formats: merge( {}, newFormats ),
- selectedNodeId: this.state.selectedNodeId + 1,
+ formatPlaceholder: record.formatPlaceholder,
} );
+ if ( newContent && newContent !== this.props.value ) {
+ this.props.onChange( newContent );
+ } else {
+ // make sure the component rerenders without refreshing the text on gutenberg
+ // (this can trigger other events that might update the active formats on aztec)
+ this.lastEventCount = 0;
+ this.forceUpdate();
+ }
}
/*
@@ -186,17 +196,14 @@ export class RichText extends Component {
return html.replace( openingTagRegexp, '' ).replace( closingTagRegexp, '' );
}
- /**
+ /*
* Handles any case where the content of the AztecRN instance has changed
*/
-
onChange( event ) {
this.lastEventCount = event.nativeEvent.eventCount;
- const contentWithoutRootTag = this.removeRootTagsProduceByAztec( event.nativeEvent.text );
+ const contentWithoutRootTag = this.removeRootTagsProduceByAztec( unescapeSpaces( event.nativeEvent.text ) );
this.lastContent = contentWithoutRootTag;
- this.props.onChange( {
- content: this.lastContent,
- } );
+ this.props.onChange( this.lastContent );
}
/**
@@ -207,18 +214,18 @@ export class RichText extends Component {
const contentHeight = contentSize.height;
this.props.onContentSizeChange( {
aztecHeight: contentHeight,
- }
- );
+ } );
}
// eslint-disable-next-line no-unused-vars
onEnter( event ) {
+ this.lastEventCount = event.nativeEvent.eventCount;
if ( ! this.props.onSplit ) {
// TODO: insert the \n char instead?
return;
}
- this.splitContent( event.nativeEvent.text, event.nativeEvent.selectionStart, event.nativeEvent.selectionEnd );
+ this.splitContent( unescapeSpaces( event.nativeEvent.text ), event.nativeEvent.selectionStart, event.nativeEvent.selectionEnd );
}
// eslint-disable-next-line no-unused-vars
@@ -246,6 +253,32 @@ export class RichText extends Component {
}
}
+ onSelectionChange( start, end, text, event ) {
+ // `end` can be less than `start` on iOS
+ // Let's fix that here so `rich-text/slice` can work properly
+ const realStart = Math.min( start, end );
+ const realEnd = Math.max( start, end );
+ const noChange = this.state.start === start && this.state.end === end;
+ const isTyping = this.state.start + 1 === realStart;
+ const shouldKeepFormats = noChange || isTyping;
+ // update format placeholder to continue writing in the current format
+ // or set it to null if user jumped to another part in the text
+ const formatPlaceholder = shouldKeepFormats && this.state.formatPlaceholder ? {
+ ...this.state.formatPlaceholder,
+ index: realStart,
+ } : null;
+ this.setState( {
+ start: realStart,
+ end: realEnd,
+ formatPlaceholder,
+ } );
+ this.lastEventCount = event.nativeEvent.eventCount;
+ // we don't want to refresh aztec as no content can have changed from this event
+ // let's update lastContent to prevent that in shouldComponentUpdate
+ this.lastContent = this.removeRootTagsProduceByAztec( unescapeSpaces( text ) );
+ this.props.onChange( this.lastContent );
+ }
+
isEmpty() {
return isEmpty( this.formatToValue( this.props.value ) );
}
@@ -276,7 +309,7 @@ export class RichText extends Component {
}
shouldComponentUpdate( nextProps ) {
- if ( nextProps.tagName !== this.props.tagName ) {
+ if ( nextProps.tagName !== this.props.tagName || nextProps.isSelected !== this.props.isSelected ) {
this.lastEventCount = undefined;
this.lastContent = undefined;
return true;
@@ -316,61 +349,18 @@ export class RichText extends Component {
}
}
- isFormatActive( format ) {
- return this.state.formats[ format ] && this.state.formats[ format ].isActive;
- }
-
- // eslint-disable-next-line no-unused-vars
- removeFormat( format ) {
- this._editor.applyFormat( format );
- }
-
- // eslint-disable-next-line no-unused-vars
- applyFormat( format, args, node ) {
- this._editor.applyFormat( format );
- }
-
- changeFormats( formats ) {
- const newStateFormats = {};
- forEach( formats, ( formatValue, format ) => {
- newStateFormats[ format ] = getFormatValue( format );
- const isActive = this.isFormatActive( format );
- if ( isActive && ! formatValue ) {
- this.removeFormat( format );
- } else if ( ! isActive && formatValue ) {
- this.applyFormat( format );
- }
- } );
-
- this.setState( ( state ) => ( {
- formats: merge( {}, state.formats, newStateFormats ),
- } ) );
- }
-
- toggleFormat( format ) {
- return () => this.changeFormats( {
- [ format ]: ! this.state.formats[ format ],
- } );
- }
-
render() {
const {
tagName,
style,
formattingControls,
- value,
+ isSelected,
} = this.props;
- const toolbarControls = FORMATTING_CONTROLS
- .filter( ( control ) => formattingControls.indexOf( control.format ) !== -1 )
- .map( ( control ) => ( {
- ...control,
- onClick: this.toggleFormat( control.format ),
- isActive: this.isFormatActive( control.format ),
- } ) );
-
+ const record = this.getRecord();
// Save back to HTML from React tree
- let html = '<' + tagName + '>' + value + '' + tagName + '>';
+ const value = this.valueToFormat( record );
+ let html = `<${ tagName }>${ value }${ tagName }>`;
// We need to check if the value is undefined or empty, and then assign it properly otherwise the placeholder is not visible
if ( value === undefined || value === '' ) {
html = '';
@@ -379,9 +369,11 @@ export class RichText extends Component {
return (
-
-
-
+ { isSelected && (
+
+
+
+ ) }
{
this._editor = ref;
@@ -389,8 +381,7 @@ export class RichText extends Component {
if ( this.props.setRef ) {
this.props.setRef( ref );
}
- }
- }
+ } }
text={ { text: html, eventCount: this.lastEventCount } }
placeholder={ this.props.placeholder }
placeholderTextColor={ this.props.placeholderTextColor || 'lightgrey' }
@@ -399,10 +390,11 @@ export class RichText extends Component {
onBlur={ this.props.onBlur }
onEnter={ this.onEnter }
onBackspace={ this.onBackspace }
+ activeFormats={ this.getActiveFormatNames( record ) }
onContentSizeChange={ this.onContentSizeChange }
- onActiveFormatsChange={ this.onActiveFormatsChange }
onCaretVerticalPositionChange={ this.props.onCaretVerticalPositionChange }
- isSelected={ this.props.isSelected }
+ onSelectionChange={ this.onSelectionChange }
+ isSelected={ isSelected }
blockType={ { tag: tagName } }
color={ 'black' }
maxImagesWidth={ 200 }
@@ -412,18 +404,26 @@ export class RichText extends Component {
fontWeight={ this.props.fontWeight }
fontStyle={ this.props.fontStyle }
/>
+ { isSelected && }
);
}
}
RichText.defaultProps = {
- formattingControls: FORMATTING_CONTROLS.map( ( { format } ) => format ),
+ formattingControls: [ 'bold', 'italic', 'link', 'strikethrough' ],
format: 'string',
};
const RichTextContainer = compose( [
withInstanceId,
+ withSelect( ( select ) => {
+ const { getFormatTypes } = select( 'core/rich-text' );
+
+ return {
+ formatTypes: getFormatTypes(),
+ };
+ } ),
] )( RichText );
RichTextContainer.Content = ( { value, format, tagName: Tag, ...props } ) => {
@@ -448,3 +448,5 @@ RichTextContainer.Content.defaultProps = {
};
export default RichTextContainer;
+export { RichTextShortcut } from './shortcut';
+export { RichTextToolbarButton } from './toolbar-button';
diff --git a/packages/editor/src/components/rich-text/shortcut.native.js b/packages/editor/src/components/rich-text/shortcut.native.js
new file mode 100644
index 00000000000000..61a62170f2768d
--- /dev/null
+++ b/packages/editor/src/components/rich-text/shortcut.native.js
@@ -0,0 +1,10 @@
+/**
+ * WordPress dependencies
+ */
+import { Component } from '@wordpress/element';
+
+export class RichTextShortcut extends Component {
+ render() {
+ return null;
+ }
+}
diff --git a/packages/editor/src/components/url-input/index.native.js b/packages/editor/src/components/url-input/index.native.js
new file mode 100644
index 00000000000000..a76252cadff39a
--- /dev/null
+++ b/packages/editor/src/components/url-input/index.native.js
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { TextInput } from 'react-native';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { withInstanceId } from '@wordpress/compose';
+
+class URLInput extends Component {
+ render() {
+ const { value = '', autoFocus = true, ...extraProps } = this.props;
+ /* eslint-disable jsx-a11y/no-autofocus */
+ return (
+
+ );
+ /* eslint-enable jsx-a11y/no-autofocus */
+ }
+}
+
+export default withInstanceId( URLInput );
diff --git a/packages/format-library/src/default-formats.js b/packages/format-library/src/default-formats.js
new file mode 100644
index 00000000000000..46d8e4b851e4a6
--- /dev/null
+++ b/packages/format-library/src/default-formats.js
@@ -0,0 +1,18 @@
+/**
+ * Internal dependencies
+ */
+import { bold } from './bold';
+import { code } from './code';
+import { image } from './image';
+import { italic } from './italic';
+import { link } from './link';
+import { strikethrough } from './strikethrough';
+
+export default [
+ bold,
+ code,
+ image,
+ italic,
+ link,
+ strikethrough,
+];
diff --git a/packages/format-library/src/default-formats.native.js b/packages/format-library/src/default-formats.native.js
new file mode 100644
index 00000000000000..30af1e7af981c5
--- /dev/null
+++ b/packages/format-library/src/default-formats.native.js
@@ -0,0 +1,16 @@
+/**
+ * Internal dependencies
+ */
+import { bold } from './bold';
+import { code } from './code';
+import { italic } from './italic';
+import { link } from './link';
+import { strikethrough } from './strikethrough';
+
+export default [
+ bold,
+ code,
+ italic,
+ link,
+ strikethrough,
+];
diff --git a/packages/format-library/src/index.js b/packages/format-library/src/index.js
index 05adec8abf40a2..d3341b31315ee3 100644
--- a/packages/format-library/src/index.js
+++ b/packages/format-library/src/index.js
@@ -1,14 +1,3 @@
-/**
- * Internal dependencies
- */
-import { bold } from './bold';
-import { code } from './code';
-import { image } from './image';
-import { italic } from './italic';
-import { link } from './link';
-import { strikethrough } from './strikethrough';
-import { underline } from './underline';
-
/**
* WordPress dependencies
*/
@@ -16,12 +5,9 @@ import {
registerFormatType,
} from '@wordpress/rich-text';
-[
- bold,
- code,
- image,
- italic,
- link,
- strikethrough,
- underline,
-].forEach( ( { name, ...settings } ) => registerFormatType( name, settings ) );
+/**
+ * Internal dependencies
+ */
+import formats from './default-formats';
+
+formats.forEach( ( { name, ...settings } ) => registerFormatType( name, settings ) );
diff --git a/packages/format-library/src/link/button.native.js b/packages/format-library/src/link/button.native.js
new file mode 100644
index 00000000000000..fa8fd004c5a37b
--- /dev/null
+++ b/packages/format-library/src/link/button.native.js
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { TouchableOpacity, View } from 'react-native';
+
+export default function Button( props ) {
+ const {
+ children,
+ onClick,
+ disabled,
+ } = props;
+
+ return (
+
+
+ { children }
+
+
+ );
+}
diff --git a/packages/format-library/src/link/index.native.js b/packages/format-library/src/link/index.native.js
new file mode 100644
index 00000000000000..4a2def1614323d
--- /dev/null
+++ b/packages/format-library/src/link/index.native.js
@@ -0,0 +1,134 @@
+/**
+ * External dependencies
+ */
+import { find } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Component, Fragment } from '@wordpress/element';
+import { withSpokenMessages } from '@wordpress/components';
+import { RichTextToolbarButton } from '@wordpress/editor';
+import {
+ applyFormat,
+ getActiveFormat,
+ getTextContent,
+ isCollapsed,
+ removeFormat,
+ slice,
+} from '@wordpress/rich-text';
+import { isURL } from '@wordpress/url';
+
+/**
+ * Internal dependencies
+ */
+import ModalLinkUI from './modal';
+
+const name = 'core/link';
+
+export const link = {
+ name,
+ title: __( 'Link' ),
+ tagName: 'a',
+ className: null,
+ attributes: {
+ url: 'href',
+ target: 'target',
+ },
+ edit: withSpokenMessages( class LinkEdit extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.addLink = this.addLink.bind( this );
+ this.stopAddingLink = this.stopAddingLink.bind( this );
+ this.onRemoveFormat = this.onRemoveFormat.bind( this );
+ this.state = {
+ addingLink: false,
+ };
+ }
+
+ addLink() {
+ const { value, onChange } = this.props;
+ const text = getTextContent( slice( value ) );
+
+ if ( text && isURL( text ) ) {
+ onChange( applyFormat( value, { type: name, attributes: { url: text } } ) );
+ } else {
+ this.setState( { addingLink: true } );
+ }
+ }
+
+ stopAddingLink() {
+ this.setState( { addingLink: false } );
+ }
+
+ getLinkSelection() {
+ const { value, isActive } = this.props;
+ const startFormat = getActiveFormat( value, 'core/link' );
+
+ // if the link isn't selected, get the link manually by looking around the cursor
+ // TODO: handle partly selected links
+ if ( startFormat && isCollapsed( value ) && isActive ) {
+ let startIndex = value.start;
+ let endIndex = value.end;
+
+ while ( find( value.formats[ startIndex ], startFormat ) ) {
+ startIndex--;
+ }
+
+ endIndex++;
+
+ while ( find( value.formats[ endIndex ], startFormat ) ) {
+ endIndex++;
+ }
+
+ return {
+ ...value,
+ start: startIndex + 1,
+ end: endIndex,
+ };
+ }
+
+ return value;
+ }
+
+ onRemoveFormat() {
+ const { onChange, speak } = this.props;
+ const linkSelection = this.getLinkSelection();
+
+ onChange( removeFormat( linkSelection, name ) );
+ speak( __( 'Link removed.' ), 'assertive' );
+ }
+
+ render() {
+ const { isActive, activeAttributes, onChange } = this.props;
+ const linkSelection = this.getLinkSelection();
+
+ return (
+
+ { this.state.addingLink &&
+
+ }
+
+
+ );
+ }
+ } ),
+};
diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js
index 2d79e11735f1c7..c700ad2ef3dc53 100644
--- a/packages/format-library/src/link/inline.js
+++ b/packages/format-library/src/link/inline.js
@@ -6,7 +6,7 @@ import classnames from 'classnames';
/**
* WordPress dependencies
*/
-import { sprintf, __ } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import { Component, createRef } from '@wordpress/element';
import {
ExternalLink,
@@ -30,39 +30,10 @@ import { URLInput, URLPopover } from '@wordpress/editor';
/**
* Internal dependencies
*/
-import { isValidHref } from './utils';
+import { createLinkFormat, isValidHref } from './utils';
const stopKeyPropagation = ( event ) => event.stopPropagation();
-/**
- * Generates the format object that will be applied to the link text.
- *
- * @param {string} url The href of the link.
- * @param {boolean} opensInNewWindow Whether this link will open in a new window.
- * @param {Object} text The text that is being hyperlinked.
- *
- * @return {Object} The final format object.
- */
-function createLinkFormat( { url, opensInNewWindow, text } ) {
- const format = {
- type: 'core/link',
- attributes: {
- url,
- },
- };
-
- if ( opensInNewWindow ) {
- // translators: accessibility label for external links, where the argument is the link text
- const label = sprintf( __( '%s (opens in a new tab)' ), text );
-
- format.attributes.target = '_blank';
- format.attributes.rel = 'noreferrer noopener';
- format.attributes[ 'aria-label' ] = label;
- }
-
- return format;
-}
-
function isShowingInput( props, state ) {
return props.addingLink || state.editLink;
}
diff --git a/packages/format-library/src/link/modal.native.js b/packages/format-library/src/link/modal.native.js
new file mode 100644
index 00000000000000..6736b4a707945f
--- /dev/null
+++ b/packages/format-library/src/link/modal.native.js
@@ -0,0 +1,175 @@
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { Switch, Text, TextInput, View } from 'react-native';
+import Modal from 'react-native-modal';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Component } from '@wordpress/element';
+import { URLInput } from '@wordpress/editor';
+import { prependHTTP } from '@wordpress/url';
+import {
+ withSpokenMessages,
+} from '@wordpress/components';
+import {
+ create,
+ insert,
+ isCollapsed,
+ applyFormat,
+ getTextContent,
+ slice,
+} from '@wordpress/rich-text';
+
+/**
+ * Internal dependencies
+ */
+import { createLinkFormat, isValidHref } from './utils';
+import Button from './button';
+
+import styles from './modal.scss';
+
+class ModalLinkUI extends Component {
+ constructor( props ) {
+ super( ...arguments );
+
+ this.submitLink = this.submitLink.bind( this );
+ this.onChangeInputValue = this.onChangeInputValue.bind( this );
+ this.onChangeText = this.onChangeText.bind( this );
+ this.onChangeOpensInNewWindow = this.onChangeOpensInNewWindow.bind( this );
+ this.removeLink = this.removeLink.bind( this );
+
+ this.state = {
+ inputValue: props.activeAttributes.url || '',
+ text: getTextContent( slice( props.value ) ),
+ opensInNewWindow: false,
+ };
+ }
+
+ onChangeInputValue( inputValue ) {
+ this.setState( { inputValue } );
+ }
+
+ onChangeText( text ) {
+ this.setState( { text } );
+ }
+
+ onChangeOpensInNewWindow( opensInNewWindow ) {
+ this.setState( { opensInNewWindow } );
+ }
+
+ submitLink() {
+ const { isActive, onChange, speak, value } = this.props;
+ const { inputValue, opensInNewWindow, text } = this.state;
+ const url = prependHTTP( inputValue );
+ const linkText = text || inputValue;
+ const format = createLinkFormat( {
+ url,
+ opensInNewWindow,
+ text: linkText,
+ } );
+ const placeholderFormats = ( value.formatPlaceholder && value.formatPlaceholder.formats ) || [];
+
+ if ( isCollapsed( value ) && ! isActive ) { // insert link
+ const toInsert = applyFormat( create( { text: linkText } ), [ ...placeholderFormats, format ], 0, text.length );
+ onChange( insert( value, toInsert ) );
+ } else if ( text !== getTextContent( slice( value ) ) ) { // edit text in selected link
+ const toInsert = applyFormat( create( { text } ), [ ...placeholderFormats, format ], 0, text.length );
+ onChange( insert( value, toInsert, value.start, value.end ) );
+ } else { // transform selected text into link
+ onChange( applyFormat( value, [ ...placeholderFormats, format ] ) );
+ }
+
+ if ( ! isValidHref( url ) ) {
+ speak( __( 'Warning: the link has been inserted but may have errors. Please test it.' ), 'assertive' );
+ } else if ( isActive ) {
+ speak( __( 'Link edited.' ), 'assertive' );
+ } else {
+ speak( __( 'Link inserted' ), 'assertive' );
+ }
+
+ this.props.onClose();
+ }
+
+ removeLink() {
+ this.props.onRemove();
+ this.props.onClose();
+ }
+
+ render() {
+ const { isVisible } = this.props;
+
+ return (
+
+
+
+
+
+
+ { __( 'Link Settings' ) }
+
+
+
+
+
+
+ { __( 'URL' ) }
+
+
+
+
+
+
+ { __( 'Link Text' ) }
+
+
+
+
+
+
+ { __( 'Open in a new window' ) }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default withSpokenMessages( ModalLinkUI );
diff --git a/packages/format-library/src/link/modal.native.scss b/packages/format-library/src/link/modal.native.scss
new file mode 100644
index 00000000000000..72f6a647b23966
--- /dev/null
+++ b/packages/format-library/src/link/modal.native.scss
@@ -0,0 +1,80 @@
+
+.bottomModal {
+ justify-content: flex-end;
+ margin: 0;
+}
+
+.dragIndicator {
+ background-color: $light-gray-400;
+ height: 4px;
+ width: 10%;
+ top: -12px;
+ margin: auto;
+ border-radius: 2px;
+}
+
+.separator {
+ background-color: $light-gray-400;
+ height: 1px;
+ width: 95%;
+ margin: auto;
+}
+
+.content {
+ background-color: $white;
+ padding: 18px 10px 5px 10px;
+ justify-content: center;
+ border-top-right-radius: 8px;
+ border-top-left-radius: 8px;
+}
+
+.head {
+ flex-direction: row;
+ width: 100%;
+ margin-bottom: 5px;
+ justify-content: space-between;
+ align-items: center;
+ align-content: center;
+}
+
+.title {
+ color: $dark-gray-600;
+ font-size: 18px;
+ font-weight: 600;
+ flex: 1;
+ text-align: center;
+}
+
+.buttonText {
+ font-size: 18px;
+ padding: 5px;
+}
+
+.inlineInput {
+ flex-direction: row;
+ width: 100%;
+ justify-content: space-between;
+ align-items: center;
+ margin: 5px 0;
+}
+
+.inlineInputLabel {
+ padding: 10px 10px;
+ color: $dark-gray-600;
+ font-size: 14px;
+ font-weight: bold;
+}
+
+.inlineInputValue {
+ flex-grow: 1;
+ font-size: 14px;
+ text-align: right;
+ align-items: stretch;
+ align-self: flex-end;
+ padding: 10px;
+}
+
+.inlineInputValueSwitch {
+ padding: 5px;
+}
+
diff --git a/packages/format-library/src/link/utils.js b/packages/format-library/src/link/utils.js
index a516917886a951..d9115cbcfe0996 100644
--- a/packages/format-library/src/link/utils.js
+++ b/packages/format-library/src/link/utils.js
@@ -18,6 +18,7 @@ import {
getFragment,
isValidFragment,
} from '@wordpress/url';
+import { __, sprintf } from '@wordpress/i18n';
/**
* Check for issues with the provided href.
@@ -78,3 +79,32 @@ export function isValidHref( href ) {
return true;
}
+
+/**
+ * Generates the format object that will be applied to the link text.
+ *
+ * @param {string} url The href of the link.
+ * @param {boolean} opensInNewWindow Whether this link will open in a new window.
+ * @param {Object} text The text that is being hyperlinked.
+ *
+ * @return {Object} The final format object.
+ */
+export function createLinkFormat( { url, opensInNewWindow, text } ) {
+ const format = {
+ type: 'core/link',
+ attributes: {
+ url,
+ },
+ };
+
+ if ( opensInNewWindow ) {
+ // translators: accessibility label for external links, where the argument is the link text
+ const label = sprintf( __( '%s (opens in a new tab)' ), text );
+
+ format.attributes.target = '_blank';
+ format.attributes.rel = 'noreferrer noopener';
+ format.attributes[ 'aria-label' ] = label;
+ }
+
+ return format;
+}
diff --git a/packages/rich-text/src/apply-format.native.js b/packages/rich-text/src/apply-format.native.js
new file mode 100644
index 00000000000000..9c5a4749ae928b
--- /dev/null
+++ b/packages/rich-text/src/apply-format.native.js
@@ -0,0 +1,83 @@
+/**
+ * External dependencies
+ */
+
+import { cloneDeep } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+
+import { normaliseFormats } from './normalise-formats';
+
+/**
+ * Apply a format object to a Rich Text value from the given `startIndex` to the
+ * given `endIndex`. Indices are retrieved from the selection if none are
+ * provided.
+ *
+ * @param {Object} value Value to modify.
+ * @param {Object} formats Formats to apply.
+ * @param {number} startIndex Start index.
+ * @param {number} endIndex End index.
+ *
+ * @return {Object} A new value with the format applied.
+ */
+export function applyFormat(
+ { formats: currentFormats, formatPlaceholder, text, start, end },
+ formats,
+ startIndex = start,
+ endIndex = end
+) {
+ if ( ! Array.isArray( formats ) ) {
+ formats = [ formats ];
+ }
+
+ // The selection is collpased, insert a placeholder with the format so new input appears
+ // with the format applied.
+ if ( startIndex === endIndex ) {
+ const previousFormats = currentFormats[ startIndex - 1 ] || [];
+ const placeholderFormats = formatPlaceholder && formatPlaceholder.index === start && formatPlaceholder.formats;
+ // Follow the same logic as in getActiveFormat: placeholderFormats has priority over previousFormats
+ const activeFormats = ( placeholderFormats ? placeholderFormats : previousFormats ) || [];
+ return {
+ formats: currentFormats,
+ text,
+ start,
+ end,
+ formatPlaceholder: {
+ index: start,
+ formats: mergeFormats( activeFormats, formats ),
+ },
+ };
+ }
+
+ const newFormats = currentFormats.slice( 0 );
+
+ for ( let index = startIndex; index < endIndex; index++ ) {
+ applyFormats( newFormats, index, formats );
+ }
+
+ return normaliseFormats( { formats: newFormats, text, start, end } );
+}
+
+function mergeFormats( formats1, formats2 ) {
+ const formatsOut = cloneDeep( formats1 );
+ formats2.forEach( ( format2 ) => {
+ const format1In2 = formatsOut.find( ( format1 ) => format1.type === format2.type );
+ // update properties while keeping the formats ordered
+ if ( format1In2 ) {
+ Object.assign( format1In2, format2 );
+ } else {
+ formatsOut.push( cloneDeep( format2 ) );
+ }
+ } );
+ return formatsOut;
+}
+
+function applyFormats( formats, index, newFormats ) {
+ if ( formats[ index ] ) {
+ formats[ index ] = mergeFormats( formats[ index ], newFormats );
+ } else {
+ formats[ index ] = cloneDeep( newFormats );
+ }
+}
diff --git a/packages/rich-text/src/get-active-format.native.js b/packages/rich-text/src/get-active-format.native.js
new file mode 100644
index 00000000000000..152070fedf278c
--- /dev/null
+++ b/packages/rich-text/src/get-active-format.native.js
@@ -0,0 +1,44 @@
+/**
+ * External dependencies
+ */
+
+import { find } from 'lodash';
+
+/**
+ * Gets the format object by type at the start of the selection. This can be
+ * used to get e.g. the URL of a link format at the current selection, but also
+ * to check if a format is active at the selection. Returns undefined if there
+ * is no format at the selection.
+ *
+ * @param {Object} value Value to inspect.
+ * @param {string} formatType Format type to look for.
+ *
+ * @return {?Object} Active format object of the specified type, or undefined.
+ */
+export function getActiveFormat( { formats, formatPlaceholder, start, end }, formatType ) {
+ if ( start === undefined ) {
+ return;
+ }
+
+ // if selection is not empty, get the first character format
+ if ( start !== end ) {
+ return find( formats[ start ], { type: formatType } );
+ }
+
+ // if user picked (or unpicked) formats but didn't write anything in those formats yet return this format
+ if ( formatPlaceholder && formatPlaceholder.index === start ) {
+ return find( formatPlaceholder.formats, { type: formatType } );
+ }
+
+ // if we're at the start of text, use the first char to pick up the formats
+ const startPos = start === 0 ? 0 : start - 1;
+
+ // otherwise get the previous character format
+ const previousLetterFormat = find( formats[ startPos ], { type: formatType } );
+
+ if ( previousLetterFormat ) {
+ return previousLetterFormat;
+ }
+
+ return undefined;
+}
diff --git a/packages/rich-text/src/normalise-formats.native.js b/packages/rich-text/src/normalise-formats.native.js
new file mode 100644
index 00000000000000..2a75e343a2c125
--- /dev/null
+++ b/packages/rich-text/src/normalise-formats.native.js
@@ -0,0 +1,36 @@
+/**
+ * Internal dependencies
+ */
+
+import { isFormatEqual } from './is-format-equal';
+
+/**
+ * Normalises formats: ensures subsequent equal formats have the same reference.
+ *
+ * @param {Object} value Value to normalise formats of.
+ *
+ * @return {Object} New value with normalised formats.
+ */
+export function normaliseFormats( { formats, formatPlaceholder, text, start, end } ) {
+ const newFormats = formats.slice( 0 );
+
+ newFormats.forEach( ( formatsAtIndex, index ) => {
+ const lastFormatsAtIndex = newFormats[ index - 1 ];
+
+ if ( lastFormatsAtIndex ) {
+ const newFormatsAtIndex = formatsAtIndex.slice( 0 );
+
+ newFormatsAtIndex.forEach( ( format, formatIndex ) => {
+ const lastFormat = lastFormatsAtIndex[ formatIndex ];
+
+ if ( isFormatEqual( format, lastFormat ) ) {
+ newFormatsAtIndex[ formatIndex ] = lastFormat;
+ }
+ } );
+
+ newFormats[ index ] = newFormatsAtIndex;
+ }
+ } );
+
+ return { formats: newFormats, formatPlaceholder, text, start, end };
+}
diff --git a/packages/rich-text/src/remove-format.native.js b/packages/rich-text/src/remove-format.native.js
new file mode 100644
index 00000000000000..b6213f813bb636
--- /dev/null
+++ b/packages/rich-text/src/remove-format.native.js
@@ -0,0 +1,69 @@
+/**
+ * External dependencies
+ */
+
+import { cloneDeep } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+
+import { normaliseFormats } from './normalise-formats';
+
+/**
+ * Remove any format object from a Rich Text value by type from the given
+ * `startIndex` to the given `endIndex`. Indices are retrieved from the
+ * selection if none are provided.
+ *
+ * @param {Object} value Value to modify.
+ * @param {string} formatType Format type to remove.
+ * @param {number} startIndex Start index.
+ * @param {number} endIndex End index.
+ *
+ * @return {Object} A new value with the format applied.
+ */
+export function removeFormat(
+ { formats, formatPlaceholder, text, start, end },
+ formatType,
+ startIndex = start,
+ endIndex = end
+) {
+ const newFormats = formats.slice( 0 );
+ let newFormatPlaceholder = null;
+
+ if ( start === end ) {
+ if ( formatPlaceholder && formatPlaceholder.index === start ) {
+ const placeholderFormats = ( formatPlaceholder.formats || [] ).slice( 0 );
+ newFormatPlaceholder = {
+ ...formatPlaceholder,
+ // make sure we do not reuse the formats reference in our placeholder `formats` array
+ formats: cloneDeep( placeholderFormats.filter( ( { type } ) => type !== formatType ) ),
+ };
+ } else if ( ! formatPlaceholder ) {
+ const previousFormat = ( start > 0 ? formats[ start - 1 ] : formats[ 0 ] ) || [];
+ newFormatPlaceholder = {
+ index: start,
+ formats: cloneDeep( previousFormat.filter( ( { type } ) => type !== formatType ) ),
+ };
+ }
+ }
+
+ // Do not remove format if selection is empty
+ for ( let i = startIndex; i < endIndex; i++ ) {
+ if ( newFormats[ i ] ) {
+ filterFormats( newFormats, i, formatType );
+ }
+ }
+
+ return normaliseFormats( { formats: newFormats, formatPlaceholder: newFormatPlaceholder, text, start, end } );
+}
+
+function filterFormats( formats, index, formatType ) {
+ const newFormats = formats[ index ].filter( ( { type } ) => type !== formatType );
+
+ if ( newFormats.length ) {
+ formats[ index ] = newFormats;
+ } else {
+ delete formats[ index ];
+ }
+}