Skip to content

Commit

Permalink
Editor: Add support for editing embeds inside a post
Browse files Browse the repository at this point in the history
This adds a dialog to edit the embed URL, but doesn't add a preview of the new URL, so it doesn't fully implement [the design in #1729](https://user-images.githubusercontent.com/191598/28643536-23f2e318-7224-11e7-8fd1-9a889d53b594.png). It's a step in that direction, though, and a future PR will add the preview.

See #1729
  • Loading branch information
iandunn committed Oct 5, 2017
1 parent 4eca50e commit ee273bb
Show file tree
Hide file tree
Showing 10 changed files with 373 additions and 1 deletion.
3 changes: 3 additions & 0 deletions client/components/tinymce/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import afterTheDeadlinePlugin from './plugins/after-the-deadline/plugin';
import wptextpatternPlugin from './plugins/wptextpattern/plugin';
import toolbarPinPlugin from './plugins/toolbar-pin/plugin';
import insertMenuPlugin from './plugins/insert-menu/plugin';
import embedPlugin from './plugins/embed/plugin';
import embedReversalPlugin from './plugins/embed-reversal/plugin';
import EditorHtmlToolbar from 'post-editor/editor-html-toolbar';
import mentionsPlugin from './plugins/mentions/plugin';
Expand Down Expand Up @@ -69,6 +70,7 @@ import wpEmojiPlugin from './plugins/wpemoji/plugin';
afterTheDeadlinePlugin,
wptextpatternPlugin,
toolbarPinPlugin,
embedPlugin,
embedReversalPlugin,
markdownPlugin,
wpEmojiPlugin,
Expand Down Expand Up @@ -115,6 +117,7 @@ const EVENTS = {

const PLUGINS = [
'colorpicker',
'embed',
'hr',
'lists',
'media',
Expand Down
109 changes: 109 additions & 0 deletions client/components/tinymce/plugins/embed/dialog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import React from 'react';
import { localize } from 'i18n-calypso';

/**
* Internal dependencies
*/
import Button from 'components/button';
import Dialog from 'components/dialog';
import FormTextInput from 'components/forms/form-text-input';

/*
* Shows the URL of am embed and allows it to be edited.
*/
export class EmbedDialog extends React.Component {
static propTypes = {
embedUrl: PropTypes.string,
isVisible: PropTypes.bool,

// Event handlers
onCancel: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,

// Inherited
translate: PropTypes.func.isRequired,
};

static defaultProps = {
embedUrl: '',
isVisible: false,
};

state = {
embedUrl: this.props.embedUrl,
};

/**
* Reset `state.embedUrl` whenever the component's dialog is opened or closed.
*
* If this were not done, then switching back and forth between multiple embeds would result in
* `state.embedUrl` being incorrect. For example, when the second embed was opened,
* `state.embedUrl` would equal the value of the first embed, since it initially set the
* state.
*
* @param {object} nextProps The properties that will be received.
*/
componentWillReceiveProps = ( nextProps ) => {
this.setState( {
embedUrl: nextProps.embedUrl,
} );
};

onChangeEmbedUrl = ( event ) => {
this.setState( { embedUrl: event.target.value } );
};

onUpdate = () => {
this.props.onUpdate( this.state.embedUrl );
};

onKeyDownEmbedUrl = ( event ) => {
if ( 'Enter' !== event.key ) {
return;
}

event.preventDefault();
this.onUpdate();
};

render() {
const { translate } = this.props;
const dialogButtons = [
<Button onClick={ this.props.onCancel }>
{ translate( 'Cancel' ) }
</Button>,
<Button primary onClick={ this.onUpdate }>
{ translate( 'Update' ) }
</Button>
];

return (
<Dialog
autoFocus={ false }
buttons={ dialogButtons }
additionalClassNames="embed__modal"
isVisible={ this.props.isVisible }
onCancel={ this.props.onCancel }
onClose={ this.props.onCancel }
>
<h3 className="embed__title">
{ translate( 'Embed URL' ) }
</h3>

<FormTextInput
autoFocus={ true }
className="embed__url"
defaultValue={ this.state.embedUrl }
onChange={ this.onChangeEmbedUrl }
onKeyDown={ this.onKeyDownEmbedUrl }
/>
</Dialog>
);
}
}

export default localize( EmbedDialog );
49 changes: 49 additions & 0 deletions client/components/tinymce/plugins/embed/docs/example.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* External dependencies
*/
import React, { PureComponent } from 'react';
import { noop } from 'lodash';

/**
* Internal dependencies
*/
import Button from 'components/button';
import Card from 'components/card';
import EmbedDialog from '../dialog';

export default class EmbedDialogExample extends PureComponent {
state = {
embedUrl: 'https://www.youtube.com/watch?v=R54QEvTyqO4',
showDialog: false,
};

openDialog = () => this.setState( { showDialog: true } );

onCancel = () => {
this.setState( { showDialog: false } );
};

onUpdate = ( newUrl ) => {
this.setState( {
embedUrl: newUrl,
showDialog: false,
} );
};

render() {
return (
<Card>
<Button onClick={ this.openDialog }>
Open Embed Dialog
</Button>

<EmbedDialog
embedUrl={ this.state.embedUrl }
isVisible={ this.state.showDialog }
onCancel={ this.onCancel }
onUpdate={ this.onUpdate }
/>
</Card>
);
}
};
64 changes: 64 additions & 0 deletions client/components/tinymce/plugins/embed/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* External dependencies
*/
import React from 'react';
import ReactDom from 'react-dom';
import tinymce from 'tinymce/tinymce';

/**
* Internal dependencies
*/
import EmbedDialog from './dialog';

/**
* Manages an EmbedDialog to allow editing the URL of an embed inside the editor.
*
* @param {object} editor An instance of TinyMCE
*/
const embed = ( editor ) => {
let embedDialogContainer;

/**
* Open or close the EmbedDialog
*
* @param {boolean} visible `true` makes the dialog visible; `false` hides it.
*/
const render = ( visible = true ) => {
const selectedEmbedNode = editor.selection.getNode();
const embedDialogProps = {
embedUrl: selectedEmbedNode.innerText || selectedEmbedNode.textContent,
isVisible: visible,
onCancel: () => render( false ),
onUpdate: ( newUrl ) => {
editor.execCommand( 'mceInsertContent', false, newUrl );
render( false );
},
};

ReactDom.render( React.createElement( EmbedDialog, embedDialogProps ), embedDialogContainer );

// Focus on the editor when closing the dialog, so that the user can start typing right away
// instead of having to tab back to the editor.
if ( ! visible ) {
editor.focus();
}
};

editor.addCommand( 'embedDialog', () => render() );

editor.on( 'init', () => {
embedDialogContainer = editor.getContainer().appendChild(
document.createElement( 'div' )
);
} );

editor.on( 'remove', () => {
ReactDom.unmountComponentAtNode( embedDialogContainer );
embedDialogContainer.parentNode.removeChild( embedDialogContainer );
embedDialogContainer = null;
} );
};

export default () => {
tinymce.PluginManager.add( 'embed', embed );
};
17 changes: 17 additions & 0 deletions client/components/tinymce/plugins/embed/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.dialog.card.embed__modal {
width: 80%;
max-width: 600px;
}

.embed__modal .dialog__action-buttons:before {
background: none;
}

.embed__title {
color: #4b6476;
font-weight: bold;
}

input[type="text"].embed__url {
margin-top: 1em;
}
118 changes: 118 additions & 0 deletions client/components/tinymce/plugins/embed/test/dialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* External dependencies
*/
import React from 'react';
import { assert } from 'chai';
import { shallow } from 'enzyme';
import { identity, noop } from 'lodash';
import { spy } from 'sinon';

/**
* Internal dependencies
*/
import Dialog from 'components/dialog';
import FormTextInput from 'components/forms/form-text-input';
import { EmbedDialog } from '../dialog';

describe( 'EmbedDialog', function() {
it( 'should render', function() {
const url = 'https://www.youtube.com/watch?v=JkOIhs2mHpc';
const wrapper = shallow(
<EmbedDialog
embedUrl={ url }
onCancel={ noop }
onUpdate={ noop }
translate={ identity }
/>
);

assert.isFalse( wrapper.instance().props.isVisible );
assert.strictEqual( wrapper.find( '.embed__title' ).length, 1 );
assert.strictEqual( wrapper.find( FormTextInput ).length, 1 );
assert.strictEqual( wrapper.find( FormTextInput ).get( 0 ).props.defaultValue, url );
} );

it( "should update the input field's value when input changes", function() {
const originalUrl = 'https://www.youtube.com/watch?v=ghrL82cc-ss';
const newUrl = 'https://videopress.com/v/DNgJlco8';
const wrapper = shallow(
<EmbedDialog
embedUrl={ originalUrl }
onCancel={ noop }
onUpdate={ noop }
translate={ identity }
/>
);
const mockChangeEvent = {
target: {
value: newUrl,
focus: noop,
}
};
let inputField = wrapper.find( FormTextInput ).get( 0 );

assert.strictEqual( inputField.props.defaultValue, originalUrl );
wrapper.find( FormTextInput ).simulate( 'change', mockChangeEvent );
inputField = wrapper.find( FormTextInput ).get( 0 );
assert.strictEqual( inputField.props.defaultValue, newUrl );
} );

it( 'should return the new url to onUpdate when updating', function() {
const originalUrl = 'https://www.youtube.com/watch?v=R54QEvTyqO4';
const newUrl = 'https://videopress.com/v/x4IYthy7';
const mockChangeEvent = {
target: {
value: newUrl,
focus: noop,
}
};
let currentUrl = originalUrl;
const onUpdate = ( url ) => {
currentUrl = url;
};
const wrapper = shallow(
<EmbedDialog
embedUrl={ originalUrl }
onCancel={ noop }
onUpdate={ onUpdate }
translate={ identity }
/>
);

assert.strictEqual( currentUrl, originalUrl );
wrapper.find( FormTextInput ).simulate( 'change', mockChangeEvent );
wrapper.instance().onUpdate();
assert.strictEqual( currentUrl, newUrl );
} );

it( 'should not return the new url to onUpdate when canceling', function() {
const originalUrl = 'https://www.youtube.com/watch?v=JkOIhs2mHpc';
const newUrl = 'https://videopress.com/v/GtWYbzhZ';
const mockChangeEvent = {
target: {
value: newUrl,
focus: noop,
}
};
const noopSpy = spy( noop );
let currentUrl = originalUrl;
const onUpdate = ( url ) => {
currentUrl = url;
};
const wrapper = shallow(
<EmbedDialog
embedUrl={ originalUrl }
onCancel={ noopSpy }
onUpdate={ onUpdate }
translate={ identity }
/>
);

assert.strictEqual( currentUrl, originalUrl );
wrapper.find( FormTextInput ).simulate( 'change', mockChangeEvent );
assert.isFalse( noopSpy.called );
wrapper.find( Dialog ).simulate( 'cancel' );
assert.isTrue( noopSpy.called );
assert.strictEqual( currentUrl, originalUrl );
} );
} );
Loading

0 comments on commit ee273bb

Please sign in to comment.