Skip to content

Commit

Permalink
Automatically create a link when selected text is a URL (#9067)
Browse files Browse the repository at this point in the history
* 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).
  • Loading branch information
noisysocks authored Aug 22, 2018
1 parent a5c89c7 commit d6ddeab
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 22 deletions.
33 changes: 20 additions & 13 deletions packages/editor/src/components/rich-text/format-toolbar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function computeDerivedState( props ) {
settingsVisible: false,
opensInNewWindow: !! props.formats.link && !! props.formats.link.target,
linkValue: '',
isEditingLink: false,
};
}

Expand Down Expand Up @@ -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 ) {
Expand All @@ -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' );
}
Expand All @@ -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 )
Expand All @@ -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 && (
<div className="editor-format-toolbar__link-modal-line editor-format-toolbar__link-settings">
<ToggleControl
Expand All @@ -215,17 +222,17 @@ class FormatToolbar extends Component {
<div className="editor-format-toolbar">
<Toolbar controls={ toolbarControls } />

{ ( isAddingLink || formats.link ) && (
{ linkUIToShow !== 'none' && (
<Fill name="RichText.Siblings">
<PositionedAtSelection className="editor-format-toolbar__link-container">
<Popover
position="bottom center"
focusOnMount={ isAddingLink ? 'firstElement' : false }
key={ selectedNodeId /* Used to force rerender on change */ }
>
{ 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 */
<form
className="editor-format-toolbar__link-modal"
onKeyPress={ stopKeyPropagation }
Expand All @@ -244,12 +251,12 @@ class FormatToolbar extends Component {
</div>
{ linkSettings }
</form>
/* 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 */
<div
className="editor-format-toolbar__link-modal"
onKeyPress={ stopKeyPropagation }
Expand All @@ -272,7 +279,7 @@ class FormatToolbar extends Component {
</div>
{ linkSettings }
</div>
/* eslint-enable jsx-a11y/no-static-element-interactions */
/* eslint-enable jsx-a11y/no-static-element-interactions */
) }
</Popover>
</PositionedAtSelection>
Expand Down
20 changes: 16 additions & 4 deletions packages/editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ),
} );
Expand Down Expand Up @@ -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. <RichText> 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 <a> 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.
Expand All @@ -853,6 +862,9 @@ export class RichText extends Component {
'data-mce-bogus': true,
} );
}

// Bail early if the link is still being added. <RichText> will ask the user
// for a URL and then update `formats.link`.
return;
}

Expand Down
9 changes: 6 additions & 3 deletions packages/url/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ npm install @wordpress/url --save
## Usage

```JS
importaddQueryArgs, prependHTTP } from '@wordpress/url';
importisURL, 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
```

<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p>
12 changes: 12 additions & 0 deletions packages/url/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
35 changes: 33 additions & 2 deletions packages/url/src/test/index.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions test/e2e/specs/__snapshots__/links.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ exports[`Links can be created by selecting text and using keyboard shortcuts 1`]
<!-- /wp:paragraph -->"
`;
exports[`Links can be created instantly when a URL is selected 1`] = `
"<!-- wp:paragraph -->
<p>This is Gutenberg: <a href=\\"https://wordpress.org/gutenberg\\">https://wordpress.org/gutenberg</a></p>
<!-- /wp:paragraph -->"
`;
exports[`Links can be created without any text selected 1`] = `
"<!-- wp:paragraph -->
<p>This is Gutenberg: <a href=\\"https://wordpress.org/gutenberg\\">https://wordpress.org/gutenberg</a></p>
Expand Down
21 changes: 21 additions & 0 deletions test/e2e/specs/links.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit d6ddeab

Please sign in to comment.