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 }` )[ 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 + ''; + const value = this.valueToFormat( record ); + let html = `<${ tagName }>${ value }`; // 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 ]; + } +}