Skip to content

Commit

Permalink
Rich Text: Indicate which text will be turned into a link (#8807)
Browse files Browse the repository at this point in the history
* Rich Text: Indicate which text will be turned into a link

When inserting a link, the text selection disappears when the focus
changes into the URLInput text field. This makes it hard to tell which
text will be turned into a link.

The Classic Editor solves this by inserting a placeholder <a> element,
which is the approach that we borrow here.

* Add E2E tests for creating, editing and removing links

* Use an `if` instead of a `switch` in getFormatValue()

A `switch` is overkill when there's only one format value that acts
differently.

* Rename Link E2E tests

Renames managing-links → links and makes the test descriptions read like
English sentences.
  • Loading branch information
noisysocks authored Aug 21, 2018
1 parent 87780df commit 4a184d5
Show file tree
Hide file tree
Showing 5 changed files with 404 additions and 143 deletions.
136 changes: 93 additions & 43 deletions packages/editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
*/
import classnames from 'classnames';
import {
isEqual,
defer,
difference,
find,
forEach,
merge,
identity,
find,
defer,
isEqual,
merge,
noop,
} from 'lodash';
import 'element-closest';
Expand Down Expand Up @@ -59,15 +60,23 @@ const { Node, getSelection } = window;
*/
const TINYMCE_ZWSP = '\uFEFF';

export function getFormatProperties( formatName, parents ) {
switch ( formatName ) {
case 'link' : {
const anchor = find( parents, ( node ) => node.nodeName.toLowerCase() === 'a' );
return !! anchor ? { value: anchor.getAttribute( 'href' ) || '', target: anchor.getAttribute( 'target' ) || '', node: anchor } : {};
export function getFormatValue( formatName, parents ) {
if ( formatName === 'link' ) {
const anchor = find( parents, ( node ) => node.nodeName === 'A' );
if ( anchor ) {
if ( anchor.hasAttribute( 'data-wp-placeholder' ) ) {
return { isAdding: true };
}
return {
isActive: true,
value: anchor.getAttribute( 'href' ) || '',
target: anchor.getAttribute( 'target' ) || '',
node: anchor,
};
}
default:
return {};
}

return { isActive: true };
}

const DEFAULT_FORMATS = [ 'bold', 'italic', 'strikethrough', 'link', 'code' ];
Expand Down Expand Up @@ -385,7 +394,6 @@ export class RichText extends Component {
/**
* Handles any case where the content of the TinyMCE instance has changed.
*/

onChange() {
this.savedContent = this.getContent();
this.props.onChange( this.savedContent );
Expand Down Expand Up @@ -699,13 +707,12 @@ export class RichText extends Component {
return;
}

// Remove *non-selected* placeholder links when the selection is changed.
this.removePlaceholderLinks( parents );

const formatNames = this.props.formattingControls;
const formats = this.editor.formatter.matchAll( formatNames ).reduce( ( accFormats, activeFormat ) => {
accFormats[ activeFormat ] = {
isActive: true,
...getFormatProperties( activeFormat, parents ),
};

accFormats[ activeFormat ] = getFormatValue( activeFormat, parents );
return accFormats;
}, {} );

Expand Down Expand Up @@ -776,6 +783,27 @@ export class RichText extends Component {
console.error( 'Formatters passed via `formatters` prop will only be registered once. Formatters can be enabled/disabled via the `formattingControls` prop.' );
}
}

// When the block is unselected, remove placeholder links and hide the formatting toolbar.
if ( ! this.props.isSelected && prevProps.isSelected ) {
this.removePlaceholderLinks();
this.setState( { formats: {} } );
}
}

/**
* Removes any placeholder links from the editor DOM. Placeholder links are
* used when adding a link to indicate which text will become a link.
*
* @param {HTMLElement[]=} linksToKeep If specified, these links will *not*
* be removed. Useful for keeping the
* currently selected link as is.
*/
removePlaceholderLinks( linksToKeep = [] ) {
const placeholderLinks = this.editor.$( 'a[data-wp-placeholder]' ).toArray();
for ( const placeholderLink of difference( placeholderLinks, linksToKeep ) ) {
this.editor.dom.remove( placeholderLink, /* keepChildren: */ true );
}
}

/**
Expand Down Expand Up @@ -809,37 +837,59 @@ export class RichText extends Component {

changeFormats( formats ) {
forEach( formats, ( formatValue, format ) => {
const isActive = this.isFormatActive( format );

if ( format === 'link' ) {
if ( !! formatValue ) {
if ( formatValue.isAdding ) {
return;
}
// Remove the selected link when `formats.link` is set to a falsey value.
if ( ! formatValue ) {
this.editor.execCommand( 'Unlink' );
return;
}

const { value: href, target } = formatValue;

if ( ! this.isFormatActive( 'link' ) && this.editor.selection.isCollapsed() ) {
// When no link or text is selected, insert a link with the URL as its text
const anchorHTML = this.editor.dom.createHTML(
'a',
{ href, target },
this.editor.dom.encode( href )
);
this.editor.insertContent( anchorHTML );
} else {
// Use built-in TinyMCE command turn the selection into a link. This takes
// care of deleting any existing links within the selection
this.editor.execCommand( 'mceInsertLink', false, { href, target } );
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`.
if ( isAdding ) {
// 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.
if ( ! isSelectionCollapsed ) {
this.editor.formatter.apply( format, {
href: '#',
'data-wp-placeholder': true,
'data-mce-bogus': true,
} );
}
} else {
this.editor.execCommand( 'Unlink' );
return;
}
} else {
const isActive = this.isFormatActive( format );
if ( isActive && ! formatValue ) {
this.removeFormat( format );
} else if ( ! isActive && formatValue ) {
this.applyFormat( format );

// When no link or text is selected, use the URL as the link's text.
if ( isSelectionCollapsed && ! isActive ) {
this.editor.insertContent( this.editor.dom.createHTML(
'a',
{ href, target },
this.editor.dom.encode( href )
) );
return;
}

// Use built-in TinyMCE command turn the selection into a link. This takes
// care of deleting any existing links within the current selection.
this.editor.execCommand( 'mceInsertLink', false, {
href,
target,
'data-wp-placeholder': null,
'data-mce-bogus': null,
} );
return;
}

if ( isActive && ! formatValue ) {
this.removeFormat( format );
} else if ( ! isActive && formatValue ) {
this.applyFormat( format );
}
} );

Expand Down
83 changes: 50 additions & 33 deletions packages/editor/src/components/rich-text/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,64 +13,81 @@ import deprecated from '@wordpress/deprecated';
*/
import {
RichText,
getFormatProperties,
getFormatValue,
} from '../';
import { diffAriaProps, pickAriaProps } from '../aria';

jest.mock( '@wordpress/deprecated', () => jest.fn() );

describe( 'getFormatProperties', () => {
const formatName = 'link';
const node = {
nodeName: 'A',
attributes: {
href: 'https://www.testing.com',
target: '_blank',
},
};
describe( 'getFormatValue', () => {
function createMockNode( nodeName, attributes = {} ) {
return {
nodeName,
hasAttribute( name ) {
return !! attributes[ name ];
},
getAttribute( name ) {
return attributes[ name ];
},
};
}

test( 'should return an empty object', () => {
expect( getFormatProperties( 'ofSomething' ) ).toEqual( {} );
test( 'basic formatting', () => {
expect( getFormatValue( 'bold' ) ).toEqual( {
isActive: true,
} );
} );

test( 'should return an empty object if no anchor element is found', () => {
expect( getFormatProperties( formatName, [ { ...node, nodeName: 'P' } ] ) ).toEqual( {} );
test( 'link formatting when no anchor is found', () => {
const formatValue = getFormatValue( 'link', [
createMockNode( 'P' ),
] );
expect( formatValue ).toEqual( {
isActive: true,
} );
} );

test( 'should return a populated object', () => {
const mockNode = {
...node,
getAttribute: jest.fn().mockImplementation( ( attr ) => mockNode.attributes[ attr ] ),
};
test( 'link formatting', () => {
const mockNode = createMockNode( 'A', {
href: 'https://www.testing.com',
target: '_blank',
} );

const parents = [
mockNode,
];
const formatValue = getFormatValue( 'link', [ mockNode ] );

expect( getFormatProperties( formatName, parents ) ).toEqual( {
expect( formatValue ).toEqual( {
isActive: true,
value: 'https://www.testing.com',
target: '_blank',
node: mockNode,
} );
} );

test( 'should return an object with empty values when no link is found', () => {
const mockNode = {
...node,
attributes: {},
getAttribute: jest.fn().mockImplementation( ( attr ) => mockNode.attributes[ attr ] ),
};
test( 'link formatting when the anchor has no attributes', () => {
const mockNode = createMockNode( 'A' );

const parents = [
mockNode,
];
const formatValue = getFormatValue( 'link', [ mockNode ] );

expect( getFormatProperties( formatName, parents ) ).toEqual( {
expect( formatValue ).toEqual( {
isActive: true,
value: '',
target: '',
node: mockNode,
} );
} );

test( 'link formatting when the link is still being added', () => {
const formatValue = getFormatValue( 'link', [
createMockNode( 'A', {
href: '#',
'data-wp-placeholder': 'true',
'data-mce-bogus': 'true',
} ),
] );
expect( formatValue ).toEqual( {
isAdding: true,
} );
} );
} );

describe( 'RichText', () => {
Expand Down
31 changes: 31 additions & 0 deletions test/e2e/specs/__snapshots__/links.test.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Links can be created by selecting text and clicking Link 1`] = `
"<!-- wp:paragraph -->
<p>This is <a href=\\"https://wordpress.org/gutenberg\\">Gutenberg</a></p>
<!-- /wp:paragraph -->"
`;
exports[`Links can be created by selecting text and using keyboard shortcuts 1`] = `
"<!-- wp:paragraph -->
<p>This is <a href=\\"https://wordpress.org/gutenberg\\">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>
<!-- /wp:paragraph -->"
`;
exports[`Links can be edited 1`] = `
"<!-- wp:paragraph -->
<p>This is <a href=\\"https://wordpress.org/gutenberg/handbook\\">Gutenberg</a></p>
<!-- /wp:paragraph -->"
`;
exports[`Links can be removed 1`] = `
"<!-- wp:paragraph -->
<p>This is Gutenberg</p>
<!-- /wp:paragraph -->"
`;
Loading

0 comments on commit 4a184d5

Please sign in to comment.