-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Editor: Add support for editing embeds inside a post
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
Showing
10 changed files
with
376 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import PropTypes from 'prop-types'; | ||
import React from 'react'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import Button from 'components/button'; | ||
import Dialog from 'components/dialog'; | ||
import FormTextInput from 'components/forms/form-text-input'; | ||
import { localize } from 'i18n-calypso'; | ||
|
||
/* | ||
* 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, | ||
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 ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import React, { PureComponent } from 'react'; | ||
import { identity, 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 } | ||
translate={ identity } | ||
/> | ||
</Card> | ||
); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
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 ); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import Dialog from 'components/dialog'; | ||
import React from 'react'; | ||
import FormTextInput from 'components/forms/form-text-input'; | ||
import { assert } from 'chai'; | ||
import { shallow } from 'enzyme'; | ||
import { identity, noop } from 'lodash'; | ||
import { spy } from 'sinon'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { EmbedDialog } from '../dialog'; | ||
|
||
describe( 'EmbedDialog', function() { | ||
it( 'should render', function() { | ||
const url = 'https://www.youtube.com/watch?v=JkOIhs2mHpc', | ||
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', | ||
newUrl = 'https://videopress.com/v/DNgJlco8', | ||
wrapper = shallow( | ||
<EmbedDialog | ||
embedUrl={ originalUrl } | ||
onCancel={ noop } | ||
onUpdate={ noop } | ||
translate={ identity } | ||
/> | ||
), | ||
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', | ||
newUrl = 'https://videopress.com/v/x4IYthy7', | ||
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', | ||
newUrl = 'https://videopress.com/v/GtWYbzhZ', | ||
mockChangeEvent = { | ||
target: { | ||
value: newUrl, | ||
focus: noop, | ||
} | ||
}, | ||
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 ); | ||
} ); | ||
} ); |
Oops, something went wrong.