瀏覽代碼

The first implementation of the feature.

Aleksander Nowodzinski 7 年之前
父節點
當前提交
cfe8118843

+ 101 - 0
packages/ckeditor5-media-embed/src/converters.js

@@ -0,0 +1,101 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/image/converters
+ */
+
+import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
+import first from '@ckeditor/ckeditor5-utils/src/first';
+import { getMediaContent, addMediaWrapperElementToFigure } from './utils';
+
+/**
+ * Returns a function that converts the media wrapper view representation:
+ *
+ *		<figure class="media"><div data-oembed-url="..."></div></figure>
+ *
+ * to the model representation:
+ *
+ *		<media url="..."></media>
+ *
+ * @returns {Function}
+ */
+export function viewFigureToModel() {
+	return dispatcher => {
+		dispatcher.on( 'element:figure', converter );
+	};
+
+	function converter( evt, data, conversionApi ) {
+		// Do not convert if this is not a "media figure".
+		if ( !conversionApi.consumable.test( data.viewItem, { name: true, classes: 'media' } ) ) {
+			return;
+		}
+
+		// Find a div wrapper element inside the figure element.
+		const viewWrapper = Array.from( data.viewItem.getChildren() ).find( viewChild => viewChild.is( 'div' ) );
+
+		// Do not convert if the div wrapper element is absent, is missing data-oembed-url attribute or was already converted.
+		if ( !viewWrapper ||
+			!viewWrapper.hasAttribute( 'data-oembed-url' ) ||
+			!conversionApi.consumable.test( viewWrapper, { name: true } ) ) {
+			return;
+		}
+
+		// Convert view wrapper to model attribute.
+		const conversionResult = conversionApi.convertItem( viewWrapper, data.modelCursor );
+
+		// Get the model wrapper from conversion result.
+		const modelWrapper = first( conversionResult.modelRange.getItems() );
+
+		// If the wrapper wasn't successfully converted, then finish conversion.
+		if ( !modelWrapper ) {
+			return;
+		}
+
+		// Set media range as conversion result.
+		data.modelRange = conversionResult.modelRange;
+
+		// Continue conversion where media conversion ends.
+		data.modelCursor = conversionResult.modelCursor;
+	}
+}
+
+export function modelToViewUrlAttributeConverter( editor, options ) {
+	return dispatcher => {
+		dispatcher.on( 'attribute:url:media', converter );
+	};
+
+	function converter( evt, data, conversionApi ) {
+		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+			return;
+		}
+
+		const viewWriter = conversionApi.writer;
+		const figure = conversionApi.mapper.toViewElement( data.item );
+		const attributes = {};
+		const wrapper = figure.getChild( 0 );
+		const withAspectWrapper = options.inEditingPipeline || options.shouldRenderContent;
+		const wrapperContent = withAspectWrapper ? getMediaContent( editor, data.attributeNewValue ) : null;
+
+		// TODO: removing it and creating it from scratch is a hack. We can do better than that.
+		if ( wrapper ) {
+			viewWriter.remove( ViewRange.createOn( wrapper ) );
+		}
+
+		if ( data.attributeNewValue !== null ) {
+			attributes[ 'data-oembed-url' ] = data.attributeNewValue;
+		}
+
+		if ( options.inEditingPipeline ) {
+			attributes.class = 'ck-media__wrapper';
+		}
+
+		addMediaWrapperElementToFigure( viewWriter, figure, {
+			withAspectWrapper,
+			wrapperContent,
+			attributes,
+		} );
+	}
+}

+ 78 - 0
packages/ckeditor5-media-embed/src/insertmediacommand.js

@@ -0,0 +1,78 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/insertmediacommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+import { getSelectedMediaElement } from './utils';
+
+/**
+ * The insert media command.
+ *
+ * The command is registered by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} as `'insertMedia'`.
+ *
+ * To insert a media at the current selection, execute the command and specify the URL:
+ *
+ *		editor.execute( 'insertMedia', 'http://url.to.the/media' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class InsertMediaCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const schema = model.schema;
+		const position = selection.getFirstPosition();
+		const selectedMedia = getSelectedMediaElement( selection );
+
+		let parent = position.parent;
+
+		if ( parent != parent.root ) {
+			parent = parent.parent;
+		}
+
+		this.value = selectedMedia ? selectedMedia.getAttribute( 'url' ) : null;
+		this.isEnabled = schema.checkChild( parent, 'media' );
+	}
+
+	/**
+	 * Executes the command, which either:
+	 *
+	 * * updates the URL of a selected media,
+	 * * inserts the new media into the editor and selects it as a whole
+	 *
+	 * @fires execute
+	 * @param {String} url The URL of the media.
+	 */
+	execute( url ) {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const selectedMedia = getSelectedMediaElement( selection );
+
+		if ( selectedMedia ) {
+			model.change( writer => {
+				writer.setAttribute( 'url', url, selectedMedia );
+			} );
+		} else {
+			const firstPosition = selection.getFirstPosition();
+			const isRoot = firstPosition.parent === firstPosition.root;
+			const insertPosition = isRoot ? ModelPosition.createAt( firstPosition ) : ModelPosition.createAfter( firstPosition.parent );
+
+			model.change( writer => {
+				const mediaElement = writer.createElement( 'media', { url } );
+
+				writer.insert( mediaElement, insertPosition );
+				writer.setSelection( mediaElement, 'on' );
+			} );
+		}
+	}
+}
+

+ 57 - 0
packages/ckeditor5-media-embed/src/mediaembed.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/mediaembed
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import MediaEmbedEditing from './mediaembedediting';
+import MediaEmbedUI from './mediaembedui';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+
+// import '../theme/mediaembed.css';
+
+/**
+ * The media embed plugin.
+ *
+ * It loads the {@link module:table/mediaembedediting~MediaEmbedEditing media embed editing feature}
+ * and {@link module:table/mediaembedui~TableUI media embed UI feature}.
+ *
+ * For a detailed overview, check the {@glink features/mediaembed Media Embed feature documentation}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class MediaEmbed extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ MediaEmbedEditing, MediaEmbedUI, Widget ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'MediaEmbed';
+	}
+}
+
+/**
+ * The configuration of the media embed features. Used by the media embed features in the `@ckeditor/ckeditor5-media-embed` package.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ * 				mediaEmbed: ... // Media embed feature options.
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @interface MediaEmbedConfig
+ */

+ 174 - 0
packages/ckeditor5-media-embed/src/mediaembedediting.js

@@ -0,0 +1,174 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/mediaembedediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import {
+	viewFigureToModel,
+	modelToViewUrlAttributeConverter
+} from './converters';
+
+import InsertMediaCommand from './insertmediacommand';
+import { toMediaWidget, createMediaFigureElement } from './utils';
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import '../theme/mediaembedediting.css';
+
+/**
+ * The media embed editing feature.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class MediaEmbedEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.define( 'mediaEmbed', {
+			media: {
+				dailymotion: {
+					url: [
+						/^(https:)?\/\/(www\.)?dailymotion\.com\/video\/(\w+)/
+					],
+					html: id =>
+						`<iframe src="https://www.dailymotion.com/embed/video/${ id }" ` +
+							'frameborder="0" width="480" height="270" allowfullscreen allow="autoplay">' +
+						'</iframe>'
+				},
+
+				instagram: {
+					url: [
+						/^(https:)?\/\/(www\.)?instagram\.com\/p\/(\w+)/
+					],
+
+					html: id =>
+						`<iframe src="http://instagram.com/p/${ id }/embed" ` +
+							'frameborder="0">' +
+						'</iframe>'
+				},
+
+				spotify: {
+					url: [
+						/^(https:)?\/\/open\.spotify\.com\/(artist\/\w+)/,
+						/^(https:)?\/\/open\.spotify\.com\/(album\/\w+)/,
+						/^(https:)?\/\/open\.spotify\.com\/(track\/\w+)/
+					],
+					html: id =>
+						`<iframe src="https://open.spotify.com/embed/${ id }" ` +
+							'frameborder="0" allowtransparency="true" allow="encrypted-media">' +
+						'</iframe>'
+				},
+
+				youtube: {
+					url: [
+						/^(https:)?\/\/(www\.)?youtube\.com\/watch\?v=(\w+)/,
+						/^(https:)?\/\/(www\.)?youtube\.com\/v\/(\w+)/,
+						/^(https:)?\/\/(www\.)?youtube\.com\/embed\/(\w+)/,
+						/^(https:)?\/\/youtu\.be\/(\w+)/
+					],
+					html: id =>
+						`<iframe src="https://www.youtube.com/embed/${ id }" ` +
+							'frameborder="0" allow="autoplay; encrypted-media" allowfullscreen>' +
+						'</iframe>'
+				},
+
+				vimeo: {
+					url: [
+						/^(https:)?\/\/vimeo\.com\/(\d+)/,
+						/^(https:)?\/\/vimeo\.com\/[^/]+\/[^/]+\/video\/(\d+)/,
+						/^(https:)?\/\/vimeo\.com\/album\/[^/]+\/video\/(\d+)/,
+						/^(https:)?\/\/vimeo\.com\/channels\/[^/]+\/(\d+)/,
+						/^(https:)?\/\/vimeo\.com\/groups\/[^/]+\/videos\/(\d+)/,
+						/^(https:)?\/\/vimeo\.com\/ondemand\/[^/]+\/(\d+)/,
+						/^(https:)?\/\/player\.vimeo\.com\/video\/(\d+)/
+					],
+					html: id =>
+						`<iframe src="https://player.vimeo.com/video/${ id }" ` +
+							'frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen>' +
+						'</iframe>'
+				},
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const t = editor.t;
+		const conversion = editor.conversion;
+		const semanticDataOutput = editor.config.get( 'mediaEmbed.semanticDataOutput' );
+
+		editor.commands.add( 'insertMedia', new InsertMediaCommand( editor ) );
+
+		// Configure the schema.
+		schema.register( 'media', {
+			isObject: true,
+			isBlock: true,
+			allowWhere: '$block',
+			allowAttributes: [ 'url' ]
+		} );
+
+		// Model -> Data
+		conversion.for( 'dataDowncast' ).add( downcastElementToElement( {
+			model: 'media',
+			view: ( modelElement, viewWriter ) => {
+				return createMediaFigureElement( viewWriter, {
+					withAspectWrapper: !semanticDataOutput
+				} );
+			}
+		} ) );
+
+		// Model -> Data (url -> data-oembed-url)
+		conversion.for( 'dataDowncast' )
+			.add( modelToViewUrlAttributeConverter( editor, {
+				shouldRenderContent: !semanticDataOutput
+			} ) );
+
+		// Model -> View (element)
+		conversion.for( 'editingDowncast' ).add( downcastElementToElement( {
+			model: 'media',
+			view: ( modelElement, viewWriter ) => {
+				const figure = createMediaFigureElement( viewWriter, {
+					witgAspectWrapper: true
+				} );
+
+				return toMediaWidget( figure, viewWriter, t( 'media widget' ) );
+			}
+		} ) );
+
+		// Model -> View (url -> data-oembed-url)
+		conversion.for( 'editingDowncast' )
+			.add( modelToViewUrlAttributeConverter( editor, {
+				inEditingPipeline: true
+			} ) );
+
+		// View -> Model (data-oembed-url -> url)
+		conversion.for( 'upcast' )
+			.add( upcastElementToElement( {
+				view: {
+					name: 'div',
+					attributes: {
+						'data-oembed-url': true
+					}
+				},
+				model: ( viewMedia, modelWriter ) => {
+					return modelWriter.createElement( 'media', {
+						url: viewMedia.getAttribute( 'data-oembed-url' )
+					} );
+				}
+			} ) )
+			.add( viewFigureToModel() );
+	}
+}

+ 110 - 0
packages/ckeditor5-media-embed/src/mediaembedui.js

@@ -0,0 +1,110 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/mediaembedui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import imageIcon from '../theme/icons/media.svg';
+import MediaFormView from './ui/mediaformview';
+import { hasMediaContent } from './utils';
+
+/**
+ * The media embed UI plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class MediaEmbedUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'insertMedia' );
+
+		// Setup `imageUpload` button.
+		editor.ui.componentFactory.add( 'insertMedia', locale => {
+			const form = new MediaFormView( getFormValidators( editor ), locale );
+			const dropdown = createDropdown( locale );
+
+			this._setUpDropdown( dropdown, form, command, editor );
+			this._setUpForm( form, dropdown, command );
+
+			return dropdown;
+		} );
+	}
+
+	_setUpDropdown( dropdown, form, command ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const button = dropdown.buttonView;
+
+		dropdown.bind( 'isEnabled' ).to( command );
+		dropdown.panelView.children.add( form );
+
+		button.set( {
+			label: t( 'Insert media' ),
+			icon: imageIcon,
+			tooltip: true
+		} );
+
+		button.on( 'open', () => {
+			// Make sure that each time the panel shows up, the URL field remains in sync with the value of
+			// the command. If the user typed in the input, then canceled (`urlInputView#value` stays
+			// unaltered) and re-opened it without changing the value of the media command (e.g. because they
+			// didn't change the selection), they would see the old value instead of the actual value of the
+			// command.
+			form.url = command.value || '';
+			form.urlInputView.select();
+			form.focus();
+		}, { priority: 'low' } );
+
+		dropdown.on( 'submit', () => {
+			if ( form.isValid() ) {
+				editor.execute( 'insertMedia', form.url );
+				closeUI();
+			}
+		} );
+
+		dropdown.on( 'change:isOpen', () => {
+			form.resetErrors();
+		} );
+
+		dropdown.on( 'cancel', () => closeUI() );
+
+		function closeUI() {
+			editor.editing.view.focus();
+			dropdown.isOpen = false;
+		}
+	}
+
+	_setUpForm( form, dropdown, command ) {
+		form.delegate( 'submit', 'cancel' ).to( dropdown );
+		form.urlInputView.bind( 'value' ).to( command, 'value' );
+
+		// Form elements should be read-only when corresponding commands are disabled.
+		form.urlInputView.bind( 'isReadOnly' ).to( command, 'isEnabled', value => !value );
+		form.saveButtonView.bind( 'isEnabled' ).to( command );
+	}
+}
+
+function getFormValidators( editor ) {
+	const t = editor.t;
+
+	return [
+		function( form ) {
+			if ( !form.url.length ) {
+				return t( 'The URL must not be empty.' );
+			}
+		},
+		function( form ) {
+			if ( !hasMediaContent( editor, form.url ) ) {
+				return t( 'This media URL is not supported.' );
+			}
+		}
+	];
+}

+ 305 - 0
packages/ckeditor5-media-embed/src/ui/mediaformview.js

@@ -0,0 +1,305 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/ui/insertmediaview
+ */
+
+import View from '../../../ckeditor5-ui/src/view';
+import ViewCollection from '../../../ckeditor5-ui/src/viewcollection';
+
+import ButtonView from '../../../ckeditor5-ui/src/button/buttonview';
+import LabeledInputView from '../../../ckeditor5-ui/src/labeledinput/labeledinputview';
+import InputTextView from '../../../ckeditor5-ui/src/inputtext/inputtextview';
+
+import submitHandler from '../../../ckeditor5-ui/src/bindings/submithandler';
+import FocusTracker from '../../../ckeditor5-utils/src/focustracker';
+import FocusCycler from '../../../ckeditor5-ui/src/focuscycler';
+import KeystrokeHandler from '../../../ckeditor5-utils/src/keystrokehandler';
+
+import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
+import '../../theme/mediaform.css';
+
+/**
+ * The media form view controller class.
+ *
+ * See {@link module:media-embed/ui/mediaformview~MediaFormView}.
+ *
+ * @extends module:ui/view~View
+ */
+export default class MediaFormView extends View {
+	/**
+	 * @param {Array.<Function>} validators Form validators used by {@link #isValid}.
+	 * @param {module:utils/locale~Locale} [locale] The localization services instance.
+	 */
+	constructor( validators, locale ) {
+		super( locale );
+
+		const t = locale.t;
+
+		/**
+		 * Tracks information about DOM focus in the form.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * The URL input view.
+		 *
+		 * @member {module:ui/labeledinput/labeledinputview~LabeledInputView}
+		 */
+		this.urlInputView = this._createUrlInput();
+
+		/**
+		 * The Save button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.saveButtonView = this._createButton( t( 'Save' ), checkIcon, 'ck-button-save' );
+		this.saveButtonView.type = 'submit';
+
+		/**
+		 * The Cancel button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'ck-button-cancel', 'cancel' );
+
+		/**
+		 * A collection of views which can be focused in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate form fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate form fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		/**
+		 * An array of the form validators used by {@link #isValid}.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {Array.<Function>}
+		 */
+		this._validators = validators;
+
+		this.setTemplate( {
+			tag: 'form',
+
+			attributes: {
+				class: [
+					'ck',
+					'ck-media-form'
+				],
+
+				tabindex: '-1'
+			},
+
+			children: [
+				this.urlInputView,
+				this.saveButtonView,
+				this.cancelButtonView
+			]
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		submitHandler( {
+			view: this
+		} );
+
+		const childViews = [
+			this.urlInputView,
+			this.saveButtonView,
+			this.cancelButtonView
+		];
+
+		childViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+
+		const stopPropagation = data => data.stopPropagation();
+
+		// Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's
+		// keystroke handler would take over the key management in the URL input. We need to prevent
+		// this ASAP.
+		this.keystrokes.set( 'arrowright', stopPropagation );
+		this.keystrokes.set( 'arrowleft', stopPropagation );
+		this.keystrokes.set( 'arrowup', stopPropagation );
+		this.keystrokes.set( 'arrowdown', stopPropagation );
+
+		// Unblock selectstart, a default behaviour of the DropdownView#panelView.
+		// TODO: blocking selectstart in the #panelView should be configurable per dropdown instance.
+		this.listenTo( this.urlInputView.element, 'selectstart', ( evt, domEvt ) => {
+			domEvt.stopPropagation();
+		}, { priority: 'high' } );
+	}
+
+	/**
+	 * Focuses the fist {@link #_focusables} in the form.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+
+	/**
+	 * The native DOM `value` of the {@link #urlInputView} element.
+	 *
+	 * **Note**: Do not confuse with the {@link module:ui/inputtext/inputtextview~InputTextView#value}
+	 * which works one way only and may not represent the actual state of the component in DOM.
+	 *
+	 * @type {Number}
+	 */
+	get url() {
+		return this.urlInputView.inputView.element.value.trim();
+	}
+
+	/**
+	 * Sets the native DOM `value` of the {@link #urlInputView} element.
+	 *
+	 * **Note**: Do not confuse with the {@link module:ui/inputtext/inputtextview~InputTextView#value}
+	 * which works one way only and may not represent the actual state of the component in DOM.
+	 *
+	 * @param {String} url
+	 */
+	set url( url ) {
+		this.urlInputView.inputView.element.value = url.trim();
+	}
+
+	/**
+	 * Validates the form and returns `false` when some fields are invalid.
+	 *
+	 * @returns {Boolean}
+	 */
+	isValid() {
+		this.resetErrors();
+
+		for ( const validator of this._validators ) {
+			const errorText = validator( this );
+
+			// One error per-field is enough.
+			if ( errorText ) {
+				// Apply updated error.
+				this.urlInputView.errorText = errorText;
+
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Returns all form fields back to the errorless state.
+	 */
+	resetErrors() {
+		this.urlInputView.errorText = false;
+	}
+
+	/**
+	 * Creates a labeled input view.
+	 *
+	 * @private
+	 * @returns {module:ui/labeledinput/labeledinputview~LabeledInputView} Labeled input view instance.
+	 */
+	_createUrlInput() {
+		const t = this.locale.t;
+
+		const labeledInput = new LabeledInputView( this.locale, InputTextView );
+
+		labeledInput.label = t( 'Media URL' );
+		labeledInput.inputView.placeholder = 'https://example.com';
+
+		return labeledInput;
+	}
+
+	/**
+	 * Creates a button view.
+	 *
+	 * @private
+	 * @param {String} label The button label.
+	 * @param {String} icon The button's icon.
+	 * @param {String} className The additional button CSS class name.
+	 * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to.
+	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
+	 */
+	_createButton( label, icon, className, eventName ) {
+		const button = new ButtonView( this.locale );
+
+		button.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
+
+		button.extendTemplate( {
+			attributes: {
+				class: className
+			}
+		} );
+
+		if ( eventName ) {
+			button.delegate( 'execute' ).to( this, eventName );
+		}
+
+		return button;
+	}
+}
+
+/**
+ * Fired when the form view is submitted (when one of the children triggered the submit event),
+ * e.g. click on {@link #saveButtonView}.
+ *
+ * @event submit
+ */
+
+/**
+ * Fired when the form view is canceled, e.g. click on {@link #cancelButtonView}.
+ *
+ * @event cancel
+ */

+ 125 - 0
packages/ckeditor5-media-embed/src/utils.js

@@ -0,0 +1,125 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module media-embed/utils
+ */
+
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
+import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+
+const mediaSymbol = Symbol( 'isMedia' );
+
+/**
+ * Converts a given {@link module:engine/view/element~Element} to a media embed widget:
+ * * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the media widget element.
+ * * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
+ *
+ * @param {module:engine/view/element~Element} viewElement
+ * @param {module:engine/view/writer~Writer} writer An instance of the view writer.
+ * @param {String} label The element's label.
+ * @returns {module:engine/view/element~Element}
+ */
+export function toMediaWidget( viewElement, writer, label ) {
+	writer.setCustomProperty( mediaSymbol, true, viewElement );
+
+	return toWidget( viewElement, writer, { label } );
+}
+
+// Creates a view element representing the media.
+//
+//		<figure class="media"></figure>
+//
+// @private
+// @param {module:engine/view/writer~Writer} writer
+// @returns {module:engine/view/containerelement~ContainerElement}
+export function createMediaFigureElement( writer, options ) {
+	const figure = writer.createContainerElement( 'figure', { class: 'media' } );
+
+	// TODO: This is a hack. Without it, the figure in the data pipeline will contain &nbsp; because
+	// its only child is the UIElement (wrapper).
+	//
+	// Note: The hack comes from widget utils; it makes the figure act like it's a widget.
+	figure.getFillerOffset = getFillerOffset;
+
+	addMediaWrapperElementToFigure( writer, figure, options );
+
+	return figure;
+}
+
+export function addMediaWrapperElementToFigure( writer, figure, options ) {
+	let renderFunction;
+
+	if ( options.withAspectWrapper ) {
+		renderFunction = function( domDocument ) {
+			const domElement = this.toDomElement( domDocument );
+
+			domElement.innerHTML =
+				`<div class="ck-media__wrapper__aspect">${ options.wrapperContent || '' }</div>`;
+
+			return domElement;
+		};
+	}
+
+	const wrapper = writer.createUIElement( 'div', options.attributes, renderFunction );
+
+	writer.insert( ViewPosition.createAt( figure ), wrapper );
+}
+
+export function getSelectedMediaElement( selection ) {
+	const selectedElement = selection.getSelectedElement();
+
+	if ( selectedElement && selectedElement.is( 'media' ) ) {
+		return selectedElement;
+	}
+
+	return null;
+}
+
+export function getMediaContent( editor, url ) {
+	const data = getContentMatchAndCreator( editor, url );
+
+	if ( data ) {
+		return data.contentCreator( data.match.pop() );
+	} else {
+		return '<p>No embeddable media found for given URL.</p>';
+	}
+}
+
+export function hasMediaContent( editor, url ) {
+	return !!getContentMatchAndCreator( editor, url );
+}
+
+function getContentMatchAndCreator( editor, url ) {
+	if ( !url ) {
+		return null;
+	}
+
+	const contentDefinitions = editor.config.get( 'mediaEmbed.media' );
+
+	url = url.trim();
+
+	for ( const name in contentDefinitions ) {
+		let { url: pattern, html: contentCreator } = contentDefinitions[ name ];
+
+		if ( !Array.isArray( pattern ) ) {
+			pattern = [ pattern ];
+		}
+
+		for ( const subPattern of pattern ) {
+			const match = url.match( subPattern );
+
+			if ( match ) {
+				return { match, contentCreator };
+			}
+		}
+	}
+
+	return null;
+}
+
+function getFillerOffset() {
+	return null;
+}

+ 32 - 0
packages/ckeditor5-media-embed/tests/manual/mediaembed.html

@@ -0,0 +1,32 @@
+<style>
+	input {
+		width: 500px;
+	}
+</style>
+
+<h2>Example URLs</h2>
+<ul>
+	<li><input type="text" value="https://www.youtube.com/watch?v=H08tGjXNHO4"></li>
+	<li><input type="text" value="https://open.spotify.com/album/2IXlgvecaDqOeF3viUZnPI?si=ogVw7KlcQAGZKK4Jz9QzvA"></li>
+</ul>
+
+<h2>Test editor</h2>
+<div id="editor">
+	<figure class="media">
+		<div data-oembed-url="https://www.youtube.com/watch?v=ZVv7UMQPEWk">
+			<iframe src="https://www.youtube.com/embed/ZVv7UMQPEWk" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen=""></iframe>
+		</div>
+	</figure>
+
+	<figure class="media">
+		<div data-oembed-url="https://vimeo.com/1084537"></div>
+	</figure>
+
+	<figure class="media">
+		<div data-oembed-url="https://open.spotify.com/artist/7GaxyUddsPok8BuhxN6OUW?si=LM7Q50vqQ4-NYnAIcMTNuQ"></div>
+	</figure>
+
+	<figure class="media">
+		<div data-oembed-url="https://ckeditor.com"></div>
+	</figure>
+</div>

+ 24 - 0
packages/ckeditor5-media-embed/tests/manual/mediaembed.js

@@ -0,0 +1,24 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import MediaEmbed from '../../src/mediaembed';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, MediaEmbed ],
+		toolbar: [
+			'heading', '|', 'insertMedia', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'link', 'undo', 'redo'
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 1 - 0
packages/ckeditor5-media-embed/tests/manual/mediaembed.md

@@ -0,0 +1 @@
+## TODO

+ 424 - 0
packages/ckeditor5-media-embed/tests/mediaembedediting.js

@@ -0,0 +1,424 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import MediaEmbedEditing from '../src/mediaembedediting';
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'MediaEmbedEditing', () => {
+	let editor, model, doc, view;
+	const mediaDefinitions = {
+		test: {
+			url: /(.*)/,
+			html: id => `<iframe src="${ id }"></iframe>`
+		}
+	};
+
+	testUtils.createSinonSandbox();
+
+	it( 'should be loaded', () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ MediaEmbedEditing ],
+			} )
+			.then( newEditor => {
+				expect( newEditor.plugins.get( MediaEmbedEditing ) ).to.be.instanceOf( MediaEmbedEditing );
+			} );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ MediaEmbedEditing ],
+			} )
+			.then( newEditor => {
+				model = newEditor.model;
+
+				expect( model.schema.checkChild( [ '$root' ], 'media' ) ).to.be.true;
+				expect( model.schema.checkAttribute( [ '$root', 'media' ], 'url' ) ).to.be.true;
+
+				expect( model.schema.isObject( 'media' ) ).to.be.true;
+
+				expect( model.schema.checkChild( [ '$root', 'media' ], 'media' ) ).to.be.false;
+				expect( model.schema.checkChild( [ '$root', 'media' ], '$text' ) ).to.be.false;
+				expect( model.schema.checkChild( [ '$root', '$block' ], 'image' ) ).to.be.false;
+			} );
+	} );
+
+	describe( 'conversion in the data pipeline', () => {
+		describe( 'semanticDataOutput=true', () => {
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ MediaEmbedEditing ],
+						mediaEmbed: {
+							semanticDataOutput: true,
+							media: mediaDefinitions
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						view = editor.editing.view;
+					} );
+			} );
+
+			describe( 'model to view', () => {
+				it( 'should convert', () => {
+					setModelData( model, '<media url="http://ckeditor.com"></media>' );
+
+					expect( editor.getData() ).to.equal(
+						'<figure class="media">' +
+							'<div data-oembed-url="http://ckeditor.com"></div>' +
+						'</figure>' );
+				} );
+
+				it( 'should convert (no url)', () => {
+					setModelData( model, '<media></media>' );
+
+					expect( editor.getData() ).to.equal(
+						'<figure class="media">' +
+							'<div></div>' +
+						'</figure>' );
+				} );
+			} );
+
+			describe( 'view to model', () => {
+				it( 'should convert media figure', () => {
+					editor.setData( '<figure class="media"><div data-oembed-url="http://ckeditor.com"></div></figure>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<media url="http://ckeditor.com"></media>' );
+				} );
+
+				it( 'should not convert if there is no media class', () => {
+					editor.setData( '<figure class="quote">My quote</figure>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '' );
+				} );
+
+				it( 'should not convert if there is no oembed wrapper inside #1', () => {
+					editor.setData( '<figure class="media"></figure>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '' );
+				} );
+
+				it( 'should not convert if there is no oembed wrapper inside #2', () => {
+					editor.setData( '<figure class="media">test</figure>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '' );
+				} );
+
+				it( 'should not convert in the wrong context', () => {
+					model.schema.register( 'div', { inheritAllFrom: '$block' } );
+					model.schema.addChildCheck( ( ctx, childDef ) => {
+						if ( ctx.endsWith( '$root' ) && childDef.name == 'media' ) {
+							return false;
+						}
+					} );
+
+					editor.conversion.elementToElement( { model: 'div', view: 'div' } );
+
+					editor.setData( '<div><figure class="media"><div data-oembed-url="http://ckeditor.com"></div></figure></div>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '<div></div>' );
+				} );
+
+				it( 'should not convert if the oembed wrapper is already consumed', () => {
+					editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+						const img = data.viewItem.getChild( 0 );
+						conversionApi.consumable.consume( img, { name: true } );
+					}, { priority: 'high' } );
+
+					editor.setData( '<figure class="media"><div data-oembed-url="http://ckeditor.com"></div></figure>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '' );
+				} );
+
+				it( 'should not convert if the figure is already consumed', () => {
+					editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+						conversionApi.consumable.consume( data.viewItem, { name: true, class: 'image' } );
+					}, { priority: 'high' } );
+
+					editor.setData( '<figure class="media"><div data-oembed-url="http://ckeditor.com"></div></figure>' );
+
+					expect( getModelData( model, { withoutSelection: true } ) )
+						.to.equal( '' );
+				} );
+			} );
+		} );
+
+		describe( 'semanticDataOutput=false', () => {
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ MediaEmbedEditing ],
+						mediaEmbed: {
+							media: mediaDefinitions
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						view = editor.editing.view;
+					} );
+			} );
+
+			describe( 'conversion in the data pipeline', () => {
+				describe( 'model to view', () => {
+					it( 'should convert', () => {
+						setModelData( model, '<media url="http://ckeditor.com"></media>' );
+
+						expect( editor.getData() ).to.equal(
+							'<figure class="media">' +
+								'<div data-oembed-url="http://ckeditor.com">' +
+									'<div class="ck-media__wrapper__aspect">' +
+										'<iframe src="http://ckeditor.com"></iframe>' +
+									'</div>' +
+								'</div>' +
+							'</figure>' );
+					} );
+
+					it( 'should convert (no url)', () => {
+						setModelData( model, '<media></media>' );
+
+						expect( editor.getData() ).to.equal(
+							'<figure class="media">' +
+								'<div>' +
+									'<div class="ck-media__wrapper__aspect"></div>' +
+								'</div>' +
+							'</figure>' );
+					} );
+				} );
+
+				describe( 'view to model', () => {
+					it( 'should convert media figure', () => {
+						editor.setData(
+							'<figure class="media">' +
+								'<div data-oembed-url="http://ckeditor.com">' +
+									'<div class="ck-media__wrapper__aspect">' +
+										'<iframe src="http://cksource.com"></iframe>' +
+									'</div>' +
+								'</div>' +
+							'</figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '<media url="http://ckeditor.com"></media>' );
+					} );
+
+					it( 'should not convert if there is no media class', () => {
+						editor.setData( '<figure class="quote">My quote</figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert if there is no oembed wrapper inside #1', () => {
+						editor.setData( '<figure class="media"></figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert if there is no oembed wrapper inside #2', () => {
+						editor.setData( '<figure class="media">test</figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert in the wrong context', () => {
+						model.schema.register( 'div', { inheritAllFrom: '$block' } );
+						model.schema.addChildCheck( ( ctx, childDef ) => {
+							if ( ctx.endsWith( '$root' ) && childDef.name == 'media' ) {
+								return false;
+							}
+						} );
+
+						editor.conversion.elementToElement( { model: 'div', view: 'div' } );
+
+						editor.setData(
+							'<div>' +
+								'<figure class="media">' +
+									'<div data-oembed-url="http://ckeditor.com">' +
+										'<div class="ck-media__wrapper__aspect">' +
+											'<iframe src="http://cksource.com"></iframe>' +
+										'</div>' +
+									'</div>' +
+								'</figure>' +
+							'</div>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '<div></div>' );
+					} );
+
+					it( 'should not convert if the oembed wrapper is already consumed', () => {
+						editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+							const img = data.viewItem.getChild( 0 );
+							conversionApi.consumable.consume( img, { name: true } );
+						}, { priority: 'high' } );
+
+						editor.setData(
+							'<div>' +
+								'<figure class="media">' +
+									'<div data-oembed-url="http://ckeditor.com">' +
+										'<div class="ck-media__wrapper__aspect">' +
+											'<iframe src="http://cksource.com"></iframe>' +
+										'</div>' +
+									'</div>' +
+								'</figure>' +
+							'</div>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+
+					it( 'should not convert if the figure is already consumed', () => {
+						editor.data.upcastDispatcher.on( 'element:figure', ( evt, data, conversionApi ) => {
+							conversionApi.consumable.consume( data.viewItem, { name: true, class: 'image' } );
+						}, { priority: 'high' } );
+
+						editor.setData( '<figure class="media"><div data-oembed-url="http://ckeditor.com"></div></figure>' );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'conversion in the editing pipeline', () => {
+		describe( 'semanticDataOutput=true', () => {
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ MediaEmbedEditing ],
+						mediaEmbed: {
+							media: mediaDefinitions,
+							semanticDataOutput: true
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						view = editor.editing.view;
+					} );
+			} );
+
+			test();
+		} );
+
+		describe( 'semanticDataOutput=false', () => {
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ MediaEmbedEditing ],
+						mediaEmbed: {
+							media: mediaDefinitions
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						view = editor.editing.view;
+					} );
+			} );
+
+			test();
+		} );
+
+		function test() {
+			describe( 'model to view', () => {
+				it( 'should convert', () => {
+					setModelData( model, '<media url="http://ckeditor.com"></media>' );
+
+					expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+						'<figure class="ck-widget media" contenteditable="false">' +
+							'<div class="ck-media__wrapper" data-oembed-url="http://ckeditor.com">' +
+								'<div class="ck-media__wrapper__aspect">' +
+									'<iframe src="http://ckeditor.com"></iframe>' +
+								'</div>' +
+							'</div>' +
+						'</figure>'
+					);
+				} );
+
+				it( 'should convert the url attribute change', () => {
+					setModelData( model, '<media url="http://ckeditor.com"></media>' );
+					const media = doc.getRoot().getChild( 0 );
+
+					model.change( writer => {
+						writer.setAttribute( 'url', 'http://cksource.com', media );
+					} );
+
+					expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+						'<figure class="ck-widget media" contenteditable="false">' +
+							'<div class="ck-media__wrapper" data-oembed-url="http://cksource.com">' +
+								'<div class="ck-media__wrapper__aspect">' +
+									'<iframe src="http://cksource.com"></iframe>' +
+								'</div>' +
+							'</div>' +
+						'</figure>'
+					);
+				} );
+
+				it( 'should convert the url attribute removal', () => {
+					setModelData( model, '<media url="http://ckeditor.com"></media>' );
+					const media = doc.getRoot().getChild( 0 );
+
+					model.change( writer => {
+						writer.removeAttribute( 'url', media );
+					} );
+
+					expect( getViewData( view, { withoutSelection: true } ) )
+						.to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper">' +
+									'<div class="ck-media__wrapper__aspect">' +
+										'<p>No embeddable media found for given URL.</p>' +
+									'</div>' +
+								'</div>' +
+							'</figure>'
+						);
+				} );
+
+				it( 'should not convert the url attribute removal if is already consumed', () => {
+					setModelData( model, '<media url="http://ckeditor.com"></media>' );
+					const media = doc.getRoot().getChild( 0 );
+
+					editor.editing.downcastDispatcher.on( 'attribute:url:media', ( evt, data, conversionApi ) => {
+						conversionApi.consumable.consume( data.item, 'attribute:url' );
+					}, { priority: 'high' } );
+
+					model.change( writer => {
+						writer.removeAttribute( 'url', media );
+					} );
+
+					expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+						'<figure class="ck-widget media" contenteditable="false">' +
+							'<div class="ck-media__wrapper" data-oembed-url="http://ckeditor.com">' +
+								'<div class="ck-media__wrapper__aspect">' +
+									'<iframe src="http://ckeditor.com"></iframe>' +
+								'</div>' +
+							'</div>' +
+						'</figure>'
+					);
+				} );
+			} );
+		}
+	} );
+} );

文件差異過大導致無法顯示
+ 1 - 0
packages/ckeditor5-media-embed/theme/icons/media.svg


+ 38 - 0
packages/ckeditor5-media-embed/theme/mediaembedediting.css

@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck-widget.media {
+	& .ck-media__wrapper {
+		max-width: 640px;
+		max-height: 360px;
+		margin: 0 auto;
+
+		& .ck-media__wrapper__aspect {
+			position: relative;
+			padding-bottom: 56.2493%;
+			height: 0;
+
+			& * {
+				pointer-events: none;
+				position: absolute;
+				width: 100%;
+				height: 100%;
+				top: 0;
+				left: 0;
+			}
+		}
+
+		/* Spotify embeds have width="300" height="380" */
+		&[data-oembed-url*="open.spotify.com"] {
+			max-width: 300px;
+			max-height: 380px;
+
+			& .ck-media__wrapper__aspect {
+				padding-bottom: 126%;
+			}
+		}
+	}
+}
+

+ 17 - 0
packages/ckeditor5-media-embed/theme/mediaform.css

@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck.ck-media-form {
+	display: flex;
+	align-items: flex-start;
+
+	& .ck-labeled-input {
+		display: inline-block;
+	}
+
+	& .ck-label {
+		display: none;
+	}
+}