From d6ddeabc9a3a0c0046c46c1a6a388405012d9ba5 Mon Sep 17 00:00:00 2001 From: Robert Anderson Date: Wed, 22 Aug 2018 10:56:15 +1000 Subject: [PATCH] Automatically create a link when selected text is a URL (#9067) * Automatically create a link when selected text is a URL When a URL is selected and the Link button is clicked or the Link keyboard shortcut is pressed, a link with that URL set as its href should be instantly created. * Simplify isURL tests * Fix URL link editing Fixes a bug that prevented the user from selecting a link which has text that looks like a URL and clicking Edit. We accomplish this by distinguishing between editing a link (isEditingLink) and adding a new link (isAddingLink). --- .../rich-text/format-toolbar/index.js | 33 ++++++++++------- .../editor/src/components/rich-text/index.js | 20 ++++++++--- packages/url/README.md | 9 +++-- packages/url/src/index.js | 12 +++++++ packages/url/src/test/index.test.js | 35 +++++++++++++++++-- .../specs/__snapshots__/links.test.js.snap | 6 ++++ test/e2e/specs/links.test.js | 21 +++++++++++ 7 files changed, 114 insertions(+), 22 deletions(-) diff --git a/packages/editor/src/components/rich-text/format-toolbar/index.js b/packages/editor/src/components/rich-text/format-toolbar/index.js index ea1a0ff185ce6..3814e2b75678a 100644 --- a/packages/editor/src/components/rich-text/format-toolbar/index.js +++ b/packages/editor/src/components/rich-text/format-toolbar/index.js @@ -68,6 +68,7 @@ function computeDerivedState( props ) { settingsVisible: false, opensInNewWindow: !! props.formats.link && !! props.formats.link.target, linkValue: '', + isEditingLink: false, }; } @@ -151,8 +152,7 @@ class FormatToolbar extends Component { editLink( event ) { event.preventDefault(); - this.props.onChange( { link: { ...this.props.formats.link, isAdding: true } } ); - this.setState( { linkValue: this.props.formats.link.value } ); + this.setState( { linkValue: this.props.formats.link.value, isEditingLink: true } ); } submitLink( event ) { @@ -165,7 +165,7 @@ class FormatToolbar extends Component { value, } } ); - this.setState( { linkValue: value } ); + this.setState( { linkValue: value, isEditingLink: false } ); if ( ! this.props.formats.link.value ) { this.props.speak( __( 'Link added.' ), 'assertive' ); } @@ -177,7 +177,7 @@ class FormatToolbar extends Component { render() { const { formats, enabledControls = DEFAULT_CONTROLS, customControls = [], selectedNodeId } = this.props; - const { linkValue, settingsVisible, opensInNewWindow } = this.state; + const { linkValue, settingsVisible, opensInNewWindow, isEditingLink } = this.state; const isAddingLink = formats.link && formats.link.isAdding; const toolbarControls = FORMATTING_CONTROLS.concat( customControls ) @@ -202,6 +202,13 @@ class FormatToolbar extends Component { }; } ); + let linkUIToShow = 'none'; + if ( isAddingLink || isEditingLink ) { + linkUIToShow = 'editing'; + } else if ( formats.link ) { + linkUIToShow = 'previewing'; + } + const linkSettings = settingsVisible && (
- { ( isAddingLink || formats.link ) && ( + { linkUIToShow !== 'none' && ( - { isAddingLink && ( - // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar - /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ + { linkUIToShow === 'editing' && ( + // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
{ linkSettings }
- /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ ) } - { formats.link && ! isAddingLink && ( - // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar - /* eslint-disable jsx-a11y/no-static-element-interactions */ + { linkUIToShow === 'previewing' && ( + // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar + /* eslint-disable jsx-a11y/no-static-element-interactions */
{ linkSettings }
- /* eslint-enable jsx-a11y/no-static-element-interactions */ + /* eslint-enable jsx-a11y/no-static-element-interactions */ ) }
diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index 55eec3c5e2b47..dffd2fe1e319d 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -30,6 +30,7 @@ import { withSelect } from '@wordpress/data'; import { rawHandler, children } from '@wordpress/blocks'; import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; import deprecated from '@wordpress/deprecated'; +import { isURL } from '@wordpress/url'; /** * Internal dependencies @@ -309,11 +310,10 @@ export class RichText extends Component { // There is a selection, check if a URL is pasted. if ( ! this.editor.selection.isCollapsed() ) { - const linkRegExp = /^(?:https?:)?\/\/\S+$/i; const pastedText = ( html || plainText ).replace( /<[^>]+>/g, '' ).trim(); // A URL was pasted, turn the selection into a link - if ( linkRegExp.test( pastedText ) ) { + if ( isURL( pastedText ) ) { this.editor.execCommand( 'mceInsertLink', false, { href: this.editor.dom.decode( pastedText ), } ); @@ -840,9 +840,18 @@ export class RichText extends Component { const { isAdding, value: href, target } = formatValue; const isSelectionCollapsed = this.editor.selection.isCollapsed(); - // Bail early if the link is still being added. will ask the user - // for a URL and then update `formats.link`. + // Are we creating a new link? if ( isAdding ) { + // If the selected text is a URL, instantly turn it into a link. + const selectedText = this.editor.selection.getContent( { format: 'text' } ); + if ( isURL( selectedText ) ) { + formatValue.isAdding = false; + this.editor.execCommand( 'mceInsertLink', false, { + href: selectedText, + } ); + return; + } + // Create a placeholder so that there's something to indicate which // text will become a link. Placeholder links are stripped from // getContent() and removed when the selection changes. @@ -853,6 +862,9 @@ export class RichText extends Component { 'data-mce-bogus': true, } ); } + + // Bail early if the link is still being added. will ask the user + // for a URL and then update `formats.link`. return; } diff --git a/packages/url/README.md b/packages/url/README.md index e50d2f142def4..9e7d61e50f271 100644 --- a/packages/url/README.md +++ b/packages/url/README.md @@ -13,13 +13,16 @@ npm install @wordpress/url --save ## Usage ```JS -import { addQueryArgs, prependHTTP } from '@wordpress/url'; +import { isURL, addQueryArgs, prependHTTP } from '@wordpress/url'; + +// Checks if the argument looks like a URL +const isURL = isURL( 'https://wordpress.org' ); // true // Appends arguments to the query string of a given url -const newUrl = addQueryArgs( 'https://google.com', { q: 'test' } ); // https://google.com/?q=test +const newURL = addQueryArgs( 'https://google.com', { q: 'test' } ); // https://google.com/?q=test // Prepends 'http://' to URLs that are probably mean to have them -const actualUrl = prependHTTP( 'wordpress.org' ); // http://wordpress.org +const actualURL = prependHTTP( 'wordpress.org' ); // http://wordpress.org ```

Code is Poetry.

diff --git a/packages/url/src/index.js b/packages/url/src/index.js index a002ccdeca0ca..fccbaa420deea 100644 --- a/packages/url/src/index.js +++ b/packages/url/src/index.js @@ -3,9 +3,21 @@ */ import { parse, stringify } from 'qs'; +const URL_REGEXP = /^(?:https?:)?\/\/\S+$/i; const EMAIL_REGEXP = /^(mailto:)?[a-z0-9._%+-]+@[a-z0-9][a-z0-9.-]*\.[a-z]{2,63}$/i; const USABLE_HREF_REGEXP = /^(?:[a-z]+:|#|\?|\.|\/)/i; +/** + * Determines whether the given string looks like a URL. + * + * @param {string} url The string to scrutinise. + * + * @return {boolean} Whether or not it looks like a URL. + */ +export function isURL( url ) { + return URL_REGEXP.test( url ); +} + /** * Appends arguments to the query string of the url * diff --git a/packages/url/src/test/index.test.js b/packages/url/src/test/index.test.js index 40fbc4fe20d53..0343ead1c1147 100644 --- a/packages/url/src/test/index.test.js +++ b/packages/url/src/test/index.test.js @@ -1,7 +1,38 @@ /** - * Internal Dependencies + * External dependencies */ -import { addQueryArgs, prependHTTP } from '../'; +import { every } from 'lodash'; + +/** + * Internal dependencies + */ +import { isURL, addQueryArgs, prependHTTP } from '../'; + +describe( 'isURL', () => { + it( 'returns true when given things that look like a URL', () => { + const urls = [ + 'http://wordpress.org', + 'https://wordpress.org', + 'HTTPS://WORDPRESS.ORG', + 'https://wordpress.org/foo#bar', + 'https://localhost/foo#bar', + ]; + + expect( every( urls, isURL ) ).toBe( true ); + } ); + + it( 'returns false when given things that don\'t look like a URL', () => { + const urls = [ + 'HTTP: HyperText Transfer Protocol', + 'URLs begin with a http:// prefix', + 'Go here: http://wordpress.org', + 'http://', + '', + ]; + + expect( every( urls, isURL ) ).toBe( false ); + } ); +} ); describe( 'addQueryArgs', () => { it( 'should append args to an URL without query string', () => { diff --git a/test/e2e/specs/__snapshots__/links.test.js.snap b/test/e2e/specs/__snapshots__/links.test.js.snap index 3bb19928ccb62..00077cd64cb30 100644 --- a/test/e2e/specs/__snapshots__/links.test.js.snap +++ b/test/e2e/specs/__snapshots__/links.test.js.snap @@ -12,6 +12,12 @@ exports[`Links can be created by selecting text and using keyboard shortcuts 1`] " `; +exports[`Links can be created instantly when a URL is selected 1`] = ` +" +

This is Gutenberg: https://wordpress.org/gutenberg

+" +`; + exports[`Links can be created without any text selected 1`] = ` "

This is Gutenberg: https://wordpress.org/gutenberg

diff --git a/test/e2e/specs/links.test.js b/test/e2e/specs/links.test.js index ece6bda4413a3..7dd10ff5d664a 100644 --- a/test/e2e/specs/links.test.js +++ b/test/e2e/specs/links.test.js @@ -100,6 +100,27 @@ describe( 'Links', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'can be created instantly when a URL is selected', async () => { + // Create a block with some text + await clickBlockAppender(); + await page.keyboard.type( 'This is Gutenberg: https://wordpress.org/gutenberg' ); + + // Select the URL + await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + + // Click on the Link button + await page.click( 'button[aria-label="Link"]' ); + + // A placeholder link should not have been inserted + expect( await page.$( 'a[data-wp-placeholder]' ) ).toBeNull(); + + // A link with the selected URL as its href should have been inserted + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + it( 'is not created when we click away from the link input', async () => { // Create a block with some text await clickBlockAppender();