Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rich Text: Indicate which text will be turned into a link #8807

Merged
merged 4 commits into from
Aug 21, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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