From c86e4d8510b8b0e72bba06f44354be30b70ab098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Remiszewski?= Date: Wed, 25 Jan 2023 15:40:28 +0100 Subject: [PATCH 1/8] Rewrite media-embed to typescript. --- packages/ckeditor5-media-embed/package.json | 10 +- .../{automediaembed.js => automediaembed.ts} | 60 ++-- .../ckeditor5-media-embed/src/converters.js | 60 ---- .../ckeditor5-media-embed/src/converters.ts | 71 ++++ .../src/{index.js => index.ts} | 0 .../ckeditor5-media-embed/src/mediaembed.js | 292 --------------- .../ckeditor5-media-embed/src/mediaembed.ts | 333 ++++++++++++++++++ ...iaembedcommand.js => mediaembedcommand.ts} | 44 +-- ...iaembedediting.js => mediaembedediting.ts} | 66 ++-- ...iaembedtoolbar.js => mediaembedtoolbar.ts} | 38 +- .../src/{mediaembedui.js => mediaembedui.ts} | 38 +- .../{mediaregistry.js => mediaregistry.ts} | 154 ++++---- .../ui/{mediaformview.js => mediaformview.ts} | 207 +++++------ packages/ckeditor5-media-embed/src/utils.js | 122 ------- packages/ckeditor5-media-embed/src/utils.ts | 125 +++++++ .../tests/insertmediacommand.js | 2 +- .../tests/mediaembedui.js | 10 +- .../tests/ui/mediaformview.js | 4 - packages/ckeditor5-media-embed/tsconfig.json | 7 + .../tsconfig.release.json | 9 + .../src/widgettoolbarrepository.ts | 2 +- 21 files changed, 858 insertions(+), 796 deletions(-) rename packages/ckeditor5-media-embed/src/{automediaembed.js => automediaembed.ts} (76%) delete mode 100644 packages/ckeditor5-media-embed/src/converters.js create mode 100644 packages/ckeditor5-media-embed/src/converters.ts rename packages/ckeditor5-media-embed/src/{index.js => index.ts} (100%) delete mode 100644 packages/ckeditor5-media-embed/src/mediaembed.js create mode 100644 packages/ckeditor5-media-embed/src/mediaembed.ts rename packages/ckeditor5-media-embed/src/{mediaembedcommand.js => mediaembedcommand.ts} (71%) rename packages/ckeditor5-media-embed/src/{mediaembedediting.js => mediaembedediting.ts} (83%) rename packages/ckeditor5-media-embed/src/{mediaembedtoolbar.js => mediaembedtoolbar.ts} (61%) rename packages/ckeditor5-media-embed/src/{mediaembedui.js => mediaembedui.ts} (71%) rename packages/ckeditor5-media-embed/src/{mediaregistry.js => mediaregistry.ts} (66%) rename packages/ckeditor5-media-embed/src/ui/{mediaformview.js => mediaformview.ts} (63%) delete mode 100644 packages/ckeditor5-media-embed/src/utils.js create mode 100644 packages/ckeditor5-media-embed/src/utils.ts create mode 100644 packages/ckeditor5-media-embed/tsconfig.json create mode 100644 packages/ckeditor5-media-embed/tsconfig.release.json diff --git a/packages/ckeditor5-media-embed/package.json b/packages/ckeditor5-media-embed/package.json index cb8432939ec..bf162a1c77a 100644 --- a/packages/ckeditor5-media-embed/package.json +++ b/packages/ckeditor5-media-embed/package.json @@ -10,7 +10,7 @@ "ckeditor5-plugin", "ckeditor5-dll" ], - "main": "src/index.js", + "main": "src/index.ts", "dependencies": { "@ckeditor/ckeditor5-ui": "^36.0.0", "ckeditor5": "^36.0.0" @@ -32,6 +32,7 @@ "@ckeditor/ckeditor5-undo": "^36.0.0", "@ckeditor/ckeditor5-utils": "^36.0.0", "@ckeditor/ckeditor5-widget": "^36.0.0", + "typescript": "^4.8.4", "webpack": "^5.58.1", "webpack-cli": "^4.9.0", "lodash-es": "^4.17.15" @@ -51,13 +52,16 @@ }, "files": [ "lang", - "src", + "src/**/*.js", + "src/**/*.d.ts", "theme", "build", "ckeditor5-metadata.json", "CHANGELOG.md" ], "scripts": { - "dll:build": "webpack" + "dll:build": "webpack", + "build": "tsc -p ./tsconfig.release.json", + "postversion": "npm run build" } } diff --git a/packages/ckeditor5-media-embed/src/automediaembed.js b/packages/ckeditor5-media-embed/src/automediaembed.ts similarity index 76% rename from packages/ckeditor5-media-embed/src/automediaembed.js rename to packages/ckeditor5-media-embed/src/automediaembed.ts index df73979a475..3ebe0550305 100644 --- a/packages/ckeditor5-media-embed/src/automediaembed.js +++ b/packages/ckeditor5-media-embed/src/automediaembed.ts @@ -7,7 +7,7 @@ * @module media-embed/automediaembed */ -import { Plugin } from 'ckeditor5/src/core'; +import { type Editor, Plugin, type PluginDependencies } from 'ckeditor5/src/core'; import { LiveRange, LivePosition } from 'ckeditor5/src/engine'; import { Clipboard } from 'ckeditor5/src/clipboard'; import { Delete } from 'ckeditor5/src/typing'; @@ -22,53 +22,48 @@ const URL_REGEXP = /^(?:http(s)?:\/\/)?[\w-]+\.[\w-.~:/?#[\]@!$&'()*+,;=%]+$/; /** * The auto-media embed plugin. It recognizes media links in the pasted content and embeds * them shortly after they are injected into the document. - * - * @extends module:core/plugin~Plugin */ export default class AutoMediaEmbed extends Plugin { /** * @inheritDoc */ - static get requires() { + public static get requires(): PluginDependencies { return [ Clipboard, Delete, Undo ]; } /** * @inheritDoc */ - static get pluginName() { + public static get pluginName(): 'AutoMediaEmbed' { return 'AutoMediaEmbed'; } + /** + * The paste–to–embed `setTimeout` ID. Stored as a property to allow + * cleaning of the timeout. + */ + private _timeoutId: number | null; + + /** + * The position where the `` element will be inserted after the timeout, + * determined each time the new content is pasted into the document. + */ + private _positionToInsert: LivePosition | null; + /** * @inheritDoc */ - constructor( editor ) { + constructor( editor: Editor ) { super( editor ); - /** - * The paste–to–embed `setTimeout` ID. Stored as a property to allow - * cleaning of the timeout. - * - * @private - * @member {Number} #_timeoutId - */ this._timeoutId = null; - - /** - * The position where the `` element will be inserted after the timeout, - * determined each time the new content is pasted into the document. - * - * @private - * @member {module:engine/model/liveposition~LivePosition} #_positionToInsert - */ this._positionToInsert = null; } /** * @inheritDoc */ - init() { + public init(): void { const editor = this.editor; const modelDocument = editor.model.document; @@ -76,7 +71,7 @@ export default class AutoMediaEmbed extends Plugin { // After pasting, the content between those positions will be checked for a URL that could be transformed // into media. this.listenTo( editor.plugins.get( 'ClipboardPipeline' ), 'inputTransformation', () => { - const firstRange = modelDocument.selection.getFirstRange(); + const firstRange = modelDocument.selection.getFirstRange()!; const leftLivePosition = LivePosition.fromPosition( firstRange.start ); leftLivePosition.stickiness = 'toPrevious'; @@ -92,10 +87,10 @@ export default class AutoMediaEmbed extends Plugin { }, { priority: 'high' } ); } ); - editor.commands.get( 'undo' ).on( 'execute', () => { + editor.commands.get( 'undo' )!.on( 'execute', () => { if ( this._timeoutId ) { global.window.clearTimeout( this._timeoutId ); - this._positionToInsert.detach(); + this._positionToInsert!.detach(); this._timeoutId = null; this._positionToInsert = null; @@ -107,11 +102,10 @@ export default class AutoMediaEmbed extends Plugin { * Analyzes the part of the document between provided positions in search for a URL representing media. * When the URL is found, it is automatically converted into media. * - * @protected - * @param {module:engine/model/liveposition~LivePosition} leftPosition Left position of the selection. - * @param {module:engine/model/liveposition~LivePosition} rightPosition Right position of the selection. + * @param leftPosition Left position of the selection. + * @param rightPosition Right position of the selection. */ - _embedMediaBetweenPositions( leftPosition, rightPosition ) { + protected _embedMediaBetweenPositions( leftPosition: LivePosition, rightPosition: LivePosition ): void { const editor = this.editor; const mediaRegistry = editor.plugins.get( MediaEmbedEditing ).registry; // TODO: Use marker instead of LiveRange & LivePositions. @@ -142,7 +136,7 @@ export default class AutoMediaEmbed extends Plugin { return; } - const mediaEmbedCommand = editor.commands.get( 'mediaEmbed' ); + const mediaEmbedCommand = editor.commands.get( 'mediaEmbed' )!; // Do not anything if media element cannot be inserted at the current position (#47). if ( !mediaEmbedCommand.isEnabled ) { @@ -162,17 +156,17 @@ export default class AutoMediaEmbed extends Plugin { writer.remove( urlRange ); urlRange.detach(); - let insertionPosition; + let insertionPosition: LivePosition | null = null; // Check if position where the media element should be inserted is still valid. // Otherwise leave it as undefined to use document.selection - default behavior of model.insertContent(). - if ( this._positionToInsert.root.rootName !== '$graveyard' ) { + if ( this._positionToInsert!.root.rootName !== '$graveyard' ) { insertionPosition = this._positionToInsert; } insertMedia( editor.model, url, insertionPosition, false ); - this._positionToInsert.detach(); + this._positionToInsert!.detach(); this._positionToInsert = null; } ); diff --git a/packages/ckeditor5-media-embed/src/converters.js b/packages/ckeditor5-media-embed/src/converters.js deleted file mode 100644 index 99cd5fcb1e3..00000000000 --- a/packages/ckeditor5-media-embed/src/converters.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module media-embed/converters - */ - -/** - * Returns a function that converts the model "url" attribute to the view representation. - * - * Depending on the configuration, the view representation can be "semantic" (for the data pipeline): - * - *
- * - *
- * - * or "non-semantic" (for the editing view pipeline): - * - *
- *
[ non-semantic media preview for "foo" ]
- *
- * - * **Note:** Changing the model "url" attribute replaces the entire content of the - * `
` in the view. - * - * @param {module:media-embed/mediaregistry~MediaRegistry} registry The registry providing - * the media and their content. - * @param {Object} options - * @param {String} [options.elementName] When set, overrides the default element name for semantic media embeds. - * @param {String} [options.renderMediaPreview] When `true`, the converter will create the view in the non-semantic form. - * @param {String} [options.renderForEditingView] When `true`, the converter will create a view specific for the - * editing pipeline (e.g. including CSS classes, content placeholders). - * @returns {Function} - */ -export function modelToViewUrlAttributeConverter( registry, options ) { - return dispatcher => { - dispatcher.on( 'attribute:url:media', converter ); - }; - - function converter( evt, data, conversionApi ) { - if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { - return; - } - - const url = data.attributeNewValue; - const viewWriter = conversionApi.writer; - const figure = conversionApi.mapper.toViewElement( data.item ); - const mediaContentElement = [ ...figure.getChildren() ] - .find( child => child.getCustomProperty( 'media-content' ) ); - - // TODO: removing the wrapper and creating it from scratch is a hack. We can do better than that. - viewWriter.remove( mediaContentElement ); - - const mediaViewElement = registry.getMediaViewElement( viewWriter, url, options ); - - viewWriter.insert( viewWriter.createPositionAt( figure, 0 ), mediaViewElement ); - } -} diff --git a/packages/ckeditor5-media-embed/src/converters.ts b/packages/ckeditor5-media-embed/src/converters.ts new file mode 100644 index 00000000000..13026c8af61 --- /dev/null +++ b/packages/ckeditor5-media-embed/src/converters.ts @@ -0,0 +1,71 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import type { GetCallback } from 'ckeditor5/src/utils'; +import type { DowncastAttributeEvent, DowncastDispatcher, Element, ViewElement } from 'ckeditor5/src/engine'; +import type MediaRegistry from './mediaregistry'; +import type { MediaOptions } from './utils'; + +/** + * @module media-embed/converters + */ + +/** + * Returns a function that converts the model "url" attribute to the view representation. + * + * Depending on the configuration, the view representation can be "semantic" (for the data pipeline): + * + * ```html + *
+ * + *
+ * ``` + * + * or "non-semantic" (for the editing view pipeline): + * + * ```html + *
+ *
[ non-semantic media preview for "foo" ]
+ *
+ * ``` + * + * **Note:** Changing the model "url" attribute replaces the entire content of the + * `
` in the view. + * + * @param registry The registry providing + * the media and their content. + * @param options options object with following properties: + * - elementName When set, overrides the default element name for semantic media embeds. + * - renderMediaPreview When `true`, the converter will create the view in the non-semantic form. + * - renderForEditingView When `true`, the converter will create a view specific for the + * editing pipeline (e.g. including CSS classes, content placeholders). + */ +export function modelToViewUrlAttributeConverter( + registry: MediaRegistry, + options: MediaOptions +): ( dispatcher: DowncastDispatcher ) => void { + const converter: GetCallback = ( evt, data, conversionApi ) => { + if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { + return; + } + + const url = data.attributeNewValue as string; + const viewWriter = conversionApi.writer; + const figure = conversionApi.mapper.toViewElement( data.item as Element )!; + const mediaContentElement = [ ...figure.getChildren() ] + .find( child => ( child as ViewElement ).getCustomProperty( 'media-content' ) )!; + + // TODO: removing the wrapper and creating it from scratch is a hack. We can do better than that. + viewWriter.remove( mediaContentElement ); + + const mediaViewElement = registry.getMediaViewElement( viewWriter, url, options ); + + viewWriter.insert( viewWriter.createPositionAt( figure, 0 ), mediaViewElement ); + }; + + return dispatcher => { + dispatcher.on( 'attribute:url:media', converter ); + }; +} diff --git a/packages/ckeditor5-media-embed/src/index.js b/packages/ckeditor5-media-embed/src/index.ts similarity index 100% rename from packages/ckeditor5-media-embed/src/index.js rename to packages/ckeditor5-media-embed/src/index.ts diff --git a/packages/ckeditor5-media-embed/src/mediaembed.js b/packages/ckeditor5-media-embed/src/mediaembed.js deleted file mode 100644 index 6648ae53d35..00000000000 --- a/packages/ckeditor5-media-embed/src/mediaembed.js +++ /dev/null @@ -1,292 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module media-embed/mediaembed - */ - -import { Plugin } from 'ckeditor5/src/core'; -import { Widget } from 'ckeditor5/src/widget'; - -import MediaEmbedEditing from './mediaembedediting'; -import AutoMediaEmbed from './automediaembed'; -import MediaEmbedUI from './mediaembedui'; - -import '../theme/mediaembed.css'; - -/** - * The media embed plugin. - * - * For a detailed overview, check the {@glink features/media-embed Media Embed feature documentation}. - * - * This is a "glue" plugin which loads the following plugins: - * - * * The {@link module:media-embed/mediaembedediting~MediaEmbedEditing media embed editing feature}, - * * The {@link module:media-embed/mediaembedui~MediaEmbedUI media embed UI feature} and - * * The {@link module:media-embed/automediaembed~AutoMediaEmbed auto-media embed feature}. - * - * @extends module:core/plugin~Plugin - */ -export default class MediaEmbed extends Plugin { - /** - * @inheritDoc - */ - static get requires() { - return [ MediaEmbedEditing, MediaEmbedUI, AutoMediaEmbed, Widget ]; - } - - /** - * @inheritDoc - */ - static get pluginName() { - return 'MediaEmbed'; - } -} - -/** - * The media embed provider descriptor. Used in - * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and - * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. - * - * See {@link module:media-embed/mediaembed~MediaEmbedConfig} to learn more. - * - * { - * name: 'example', - * - * // The following RegExp matches https://www.example.com/media/{media id}, - * // (either with "http(s)://" and "www" or without), so the valid URLs are: - * // - * // * https://www.example.com/media/{media id}, - * // * http://www.example.com/media/{media id}, - * // * www.example.com/media/{media id}, - * // * example.com/media/{media id} - * url: /^example\.com\/media\/(\w+)/, - * - * // The rendering function of the provider. - * // Used to represent the media when editing the content (i.e. in the view) - * // and also in the data output of the editor if semantic data output is disabled. - * html: match => `The HTML representing the media with ID=${ match[ 1 ] }.` - * } - * - * You can allow any sort of media in the editor using the "allow–all" `RegExp`. - * But mind that, since URLs are processed in the order of configuration, if one of the previous - * `RegExps` matches the URL, it will have a precedence over this one. - * - * { - * name: 'allow-all', - * url: /^.+/ - * } - * - * To implement responsive media, you can use the following HTML structure: - * - * { - * ... - * html: match => - * '
' + - * '' + - * '
' - * } - * - * @typedef {Object} module:media-embed/mediaembed~MediaEmbedProvider - * @property {String} name The name of the provider. Used e.g. when - * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders removing providers}. - * @property {RegExp|Array.} url The `RegExp` object (or array of objects) defining the URL of the media. - * If any URL matches the `RegExp`, it becomes the media in the editor model, as defined by the provider. The result - * of matching (output of `String.prototype.match()`) is passed to the `html` rendering function of the media. - * - * **Note:** You do not need to include the protocol (`http://`, `https://`) and `www` subdomain in your `RegExps`, - * they are stripped from the URLs before matching anyway. - * @property {Function} [html] (optional) The rendering function of the media. The function receives the entire matching - * array from the corresponding `url` `RegExp` as an argument, allowing rendering a dedicated - * preview of the media identified by a certain ID or a hash. When not defined, the media embed feature - * will use a generic media representation in the view and output data. - * Note that when - * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} - * is `true`, the rendering function **will always** be used for the media in the editor data output. - */ - -/** - * The configuration of the {@link module:media-embed/mediaembed~MediaEmbed} feature. - * - * Read more in {@link module:media-embed/mediaembed~MediaEmbedConfig}. - * - * @member {module:media-embed/mediaembed~MediaEmbedConfig} module:core/editor/editorconfig~EditorConfig#mediaEmbed - */ - -/** - * The configuration of the media embed features. - * - * Read more about {@glink features/media-embed#configuration configuring the media embed feature}. - * - * ClassicEditor - * .create( editorElement, { - * mediaEmbed: ... // Media embed feature options. - * } ) - * .then( ... ) - * .catch( ... ); - * - * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. - * - * @interface MediaEmbedConfig - */ - -/** - * The default media providers supported by the editor. - * - * The names of providers with rendering functions (previews): - * - * * "dailymotion", - * * "spotify", - * * "youtube", - * * "vimeo" - * - * The names of providers without rendering functions: - * - * * "instagram", - * * "twitter", - * * "googleMaps", - * * "flickr", - * * "facebook" - * - * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more about - * different kinds of media and media providers. - * - * **Note**: The default media provider configuration may not support all possible media URLs, - * only the most common are included. - * - * Media without rendering functions are always represented in the data using the "semantic" markup. See - * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} to - * learn more about possible data outputs. - * - * The priority of media providers corresponds to the order of configuration. The first provider - * to match the URL is always used, even if there are other providers that support a particular URL. - * The URL is never matched against the remaining providers. - * - * To discard **all** default media providers, simply override this configuration with your own - * {@link module:media-embed/mediaembed~MediaEmbedProvider definitions}: - * - * ClassicEditor - * .create( editorElement, { - * plugins: [ MediaEmbed, ... ], - * mediaEmbed: { - * providers: [ - * { - * name: 'myProvider', - * url: /^example\.com\/media\/(\w+)/, - * html: match => '...' - * }, - * ... - * ] - * } - * } ) - * .then( ... ) - * .catch( ... ); - * - * You can take inspiration from the default configuration of this feature which you can find in: - * https://github.com/ckeditor/ckeditor5-media-embed/blob/master/src/mediaembedediting.js - * - * To **extend** the list of default providers, use - * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. - * - * To **remove** certain providers, use - * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders `config.mediaEmbed.removeProviders`}. - * - * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#providers - */ - -/** - * The additional media providers supported by the editor. This configuration helps extend the default - * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers}. - * - * ClassicEditor - * .create( editorElement, { - * plugins: [ MediaEmbed, ... ], - * mediaEmbed: { - * extraProviders: [ - * { - * name: 'extraProvider', - * url: /^example\.com\/media\/(\w+)/, - * html: match => '...' - * }, - * ... - * ] - * } - * } ) - * .then( ... ) - * .catch( ... ); - * - * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more. - * - * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#extraProviders - */ - -/** - * The list of media providers that should not be used despite being available in - * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and - * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`} - * - * mediaEmbed: { - * removeProviders: [ 'youtube', 'twitter' ] - * } - * - * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#removeProviders - */ - -/** - * Overrides the element name used for "semantic" data. - * - * This is not relevant if {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} - * is set to `true`. - * - * When not set, the feature produces the `` tag: - * - *
- * - *
- * - * To override the element name with, for instance, the `o-embed` name: - * - * mediaEmbed: { - * elementName: 'o-embed' - * } - * - * This will produce semantic data with the `` tag: - * - *
- * - *
- * - * @default 'oembed' - * @member {String} [module:media-embed/mediaembed~MediaEmbedConfig#elementName] - */ - -/** - * Controls the data format produced by the feature. - * - * When `false` (default), the feature produces "semantic" data, i.e. it does not include the preview of - * the media, just the `` tag with the `url` attribute: - * - *
- * - *
- * - * When `true`, the media is represented in the output in the same way it looks in the editor, - * i.e. the media preview is saved to the database: - * - *
- *
- * - *
- *
- * - * **Note:** Media without preview are always represented in the data using the "semantic" markup - * regardless of the value of the `previewsInData`. Learn more about different kinds of media - * in the {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} - * configuration description. - * - * @member {Boolean} [module:media-embed/mediaembed~MediaEmbedConfig#previewsInData=false] - */ diff --git a/packages/ckeditor5-media-embed/src/mediaembed.ts b/packages/ckeditor5-media-embed/src/mediaembed.ts new file mode 100644 index 00000000000..5d0018cc360 --- /dev/null +++ b/packages/ckeditor5-media-embed/src/mediaembed.ts @@ -0,0 +1,333 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembed + */ + +import { Plugin, type PluginDependencies } from 'ckeditor5/src/core'; +import { Widget } from 'ckeditor5/src/widget'; + +import MediaEmbedEditing from './mediaembedediting'; +import AutoMediaEmbed from './automediaembed'; +import MediaEmbedUI from './mediaembedui'; + +import '../theme/mediaembed.css'; +import type { ArrayOrItem } from 'ckeditor5/src/utils'; + +/** + * The media embed plugin. + * + * For a detailed overview, check the {@glink features/media-embed Media Embed feature documentation}. + * + * This is a "glue" plugin which loads the following plugins: + * + * * The {@link module:media-embed/mediaembedediting~MediaEmbedEditing media embed editing feature}, + * * The {@link module:media-embed/mediaembedui~MediaEmbedUI media embed UI feature} and + * * The {@link module:media-embed/automediaembed~AutoMediaEmbed auto-media embed feature}. + */ +export default class MediaEmbed extends Plugin { + /** + * @inheritDoc + */ + public static get requires(): PluginDependencies { + return [ MediaEmbedEditing, MediaEmbedUI, AutoMediaEmbed, Widget ]; + } + + /** + * @inheritDoc + */ + public static get pluginName(): 'MediaEmbed' { + return 'MediaEmbed'; + } +} + +/** + * The media embed provider descriptor. Used in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * See {@link module:media-embed/mediaembed~MediaEmbedConfig} to learn more. + * + * ```ts + * { + * name: 'example', + * + * // The following RegExp matches https://www.example.com/media/{media id}, + * // (either with "http(s)://" and "www" or without), so the valid URLs are: + * // + * // * https://www.example.com/media/{media id}, + * // * http://www.example.com/media/{media id}, + * // * www.example.com/media/{media id}, + * // * example.com/media/{media id} + * url: /^example\.com\/media\/(\w+)/, + * + * // The rendering function of the provider. + * // Used to represent the media when editing the content (i.e. in the view) + * // and also in the data output of the editor if semantic data output is disabled. + * html: match => `The HTML representing the media with ID=${ match[ 1 ] }.` + * } + * ``` + * + * You can allow any sort of media in the editor using the "allow–all" `RegExp`. + * But mind that, since URLs are processed in the order of configuration, if one of the previous + * `RegExps` matches the URL, it will have a precedence over this one. + * + * ```ts + * { + * name: 'allow-all', + * url: /^.+/ + * } + * ``` + * + * To implement responsive media, you can use the following HTML structure: + * + * ```ts + * { + * ... + * html: match => + * '
' + + * '' + + * '
' + * } + * ``` + */ +export interface MediaEmbedProvider { + + /** + * The name of the provider. Used e.g. when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders removing providers}. + */ + name: string; + + /** + * The `RegExp` object (or array of objects) defining the URL of the media. + * If any URL matches the `RegExp`, it becomes the media in the editor model, as defined by the provider. The result + * of matching (output of `String.prototype.match()`) is passed to the `html` rendering function of the media. + * + * **Note:** You do not need to include the protocol (`http://`, `https://`) and `www` subdomain in your `RegExps`, + * they are stripped from the URLs before matching anyway. + */ + url: ArrayOrItem; + + /** + * The rendering function of the media. The function receives the entire matching + * array from the corresponding `url` `RegExp` as an argument, allowing rendering a dedicated + * preview of the media identified by a certain ID or a hash. When not defined, the media embed feature + * will use a generic media representation in the view and output data. + * Note that when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is `true`, the rendering function **will always** be used for the media in the editor data output. + */ + html?: ( match: RegExpExecArray ) => string; +} + +/** + * The configuration of the media embed features. + * + * Read more about {@glink features/media-embed#configuration configuring the media embed feature}. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * mediaEmbed: ... // Media embed feature options. + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + */ +export interface MediaEmbedConfig { + + /** + * The default media providers supported by the editor. + * + * The names of providers with rendering functions (previews): + * + * * "dailymotion", + * * "spotify", + * * "youtube", + * * "vimeo" + * + * The names of providers without rendering functions: + * + * * "instagram", + * * "twitter", + * * "googleMaps", + * * "flickr", + * * "facebook" + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more about + * different kinds of media and media providers. + * + * **Note**: The default media provider configuration may not support all possible media URLs, + * only the most common are included. + * + * Media without rendering functions are always represented in the data using the "semantic" markup. See + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} to + * learn more about possible data outputs. + * + * The priority of media providers corresponds to the order of configuration. The first provider + * to match the URL is always used, even if there are other providers that support a particular URL. + * The URL is never matched against the remaining providers. + * + * To discard **all** default media providers, simply override this configuration with your own + * {@link module:media-embed/mediaembed~MediaEmbedProvider definitions}: + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * providers: [ + * { + * name: 'myProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * You can take inspiration from the default configuration of this feature which you can find in: + * https://github.com/ckeditor/ckeditor5-media-embed/blob/master/src/mediaembedediting.js + * + * To **extend** the list of default providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * To **remove** certain providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders `config.mediaEmbed.removeProviders`}. + */ + providers?: Array; + + /** + * The additional media providers supported by the editor. This configuration helps extend the default + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers}. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * extraProviders: [ + * { + * name: 'extraProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more. + */ + extraProviders?: Array; + + /** + * The list of media providers that should not be used despite being available in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`} + * + * ```ts + * mediaEmbed: { + * removeProviders: [ 'youtube', 'twitter' ] + * } + * ``` + */ + removeProviders: Array; + + /** + * Overrides the element name used for "semantic" data. + * + * This is not relevant if {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is set to `true`. + * + * When not set, the feature produces the `` tag: + * + * ```html + *
+ * + *
+ * ``` + * + * To override the element name with, for instance, the `o-embed` name: + * + * ```ts + * mediaEmbed: { + * elementName: 'o-embed' + * } + * ``` + * + * This will produce semantic data with the `` tag: + * + * ```html + *
+ * + *
+ * ``` + * + * @default 'oembed' + */ + elementName?: string; + + /** + * Controls the data format produced by the feature. + * + * When `false` (default), the feature produces "semantic" data, i.e. it does not include the preview of + * the media, just the `` tag with the `url` attribute: + * + * ```ts + *
+ * + *
+ * ``` + * + * When `true`, the media is represented in the output in the same way it looks in the editor, + * i.e. the media preview is saved to the database: + * + * ```ts + *
+ *
+ * + *
+ *
+ * ``` + * + * **Note:** Media without preview are always represented in the data using the "semantic" markup + * regardless of the value of the `previewsInData`. Learn more about different kinds of media + * in the {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} + * configuration description. + * + * @defualt false + */ + previewsInData?: boolean; +} + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ MediaEmbed.pluginName ]: MediaEmbed; + } + + interface EditorConfig { + + /** + * The configuration of the {@link module:media-embed/mediaembed~MediaEmbed} feature. + * + * Read more in {@link module:media-embed/mediaembed~MediaEmbedConfig}. + */ + mediaEmbed?: MediaEmbedConfig; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaembedcommand.js b/packages/ckeditor5-media-embed/src/mediaembedcommand.ts similarity index 71% rename from packages/ckeditor5-media-embed/src/mediaembedcommand.js rename to packages/ckeditor5-media-embed/src/mediaembedcommand.ts index c17ddf81680..c745b8cc345 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedcommand.js +++ b/packages/ckeditor5-media-embed/src/mediaembedcommand.ts @@ -7,8 +7,10 @@ * @module media-embed/mediaembedcommand */ +import type { DocumentSelection, Element, Model, Selection } from 'ckeditor5/src/engine'; import { Command } from 'ckeditor5/src/core'; import { findOptimalInsertionRange } from 'ckeditor5/src/widget'; + import { getSelectedMediaModelWidget, insertMedia } from './utils'; /** @@ -18,20 +20,25 @@ import { getSelectedMediaModelWidget, insertMedia } from './utils'; * * To insert media at the current selection, execute the command and specify the URL: * - * editor.execute( 'mediaEmbed', 'http://url.to.the/media' ); - * - * @extends module:core/command~Command + * ```ts + * editor.execute( 'mediaEmbed', 'http://url.to.the/media' ); + * ``` */ export default class MediaEmbedCommand extends Command { + /** + * Media url. + */ + declare public value: string | undefined; + /** * @inheritDoc */ - refresh() { + public override refresh(): void { const model = this.editor.model; const selection = model.document.selection; const selectedMedia = getSelectedMediaModelWidget( selection ); - this.value = selectedMedia ? selectedMedia.getAttribute( 'url' ) : null; + this.value = selectedMedia ? selectedMedia.getAttribute( 'url' ) as string : undefined; this.isEnabled = isMediaSelected( selection ) || isAllowedInParent( selection, model ); } @@ -43,9 +50,9 @@ export default class MediaEmbedCommand extends Command { * * inserts the new media into the editor and puts the selection around it. * * @fires execute - * @param {String} url The URL of the media. + * @param url The URL of the media. */ - execute( url ) { + public override execute( url: string ): void { const model = this.editor.model; const selection = model.document.selection; const selectedMedia = getSelectedMediaModelWidget( selection ); @@ -60,28 +67,25 @@ export default class MediaEmbedCommand extends Command { } } -// Checks if the table is allowed in the parent. -// -// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection -// @param {module:engine/model/model~Model} model -// @returns {Boolean} -function isAllowedInParent( selection, model ) { +/** + * Checks if the table is allowed in the parent. + */ +function isAllowedInParent( selection: Selection | DocumentSelection, model: Model ): boolean { const insertionRange = findOptimalInsertionRange( selection, model ); - let parent = insertionRange.start.parent; + let parent = insertionRange.start.parent as Element; // The model.insertContent() will remove empty parent (unless it is a $root or a limit). if ( parent.isEmpty && !model.schema.isLimit( parent ) ) { - parent = parent.parent; + parent = parent.parent as Element; } return model.schema.checkChild( parent, 'media' ); } -// Checks if the media object is selected. -// -// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection -// @returns {Boolean} -function isMediaSelected( selection ) { +/** + * Checks if the media object is selected. + */ +function isMediaSelected( selection: Selection | DocumentSelection ): boolean { const element = selection.getSelectedElement(); return !!element && element.name === 'media'; } diff --git a/packages/ckeditor5-media-embed/src/mediaembedediting.js b/packages/ckeditor5-media-embed/src/mediaembedediting.ts similarity index 83% rename from packages/ckeditor5-media-embed/src/mediaembedediting.js rename to packages/ckeditor5-media-embed/src/mediaembedediting.ts index 17d52e3139c..bdc2870f8b0 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedediting.js +++ b/packages/ckeditor5-media-embed/src/mediaembedediting.ts @@ -7,8 +7,8 @@ * @module media-embed/mediaembedediting */ -import { Plugin } from 'ckeditor5/src/core'; -import { first } from 'ckeditor5/src/utils'; +import { Plugin, type Editor } from 'ckeditor5/src/core'; +import { first, type GetCallback } from 'ckeditor5/src/utils'; import { modelToViewUrlAttributeConverter } from './converters'; import MediaEmbedCommand from './mediaembedcommand'; @@ -16,26 +16,31 @@ import MediaRegistry from './mediaregistry'; import { toMediaWidget, createMediaFigureElement } from './utils'; import '../theme/mediaembedediting.css'; +import type { MediaEmbedConfig } from './mediaembed'; +import type { UpcastElementEvent } from 'ckeditor5/src/engine'; /** * The media embed editing feature. - * - * @extends module:core/plugin~Plugin */ export default class MediaEmbedEditing extends Plugin { /** * @inheritDoc */ - static get pluginName() { + public static get pluginName(): 'MediaEmbedEditing' { return 'MediaEmbedEditing'; } + /** + * The media registry managing the media providers in the editor. + */ + public registry: MediaRegistry; + /** * @inheritDoc */ - constructor( editor ) { + constructor( editor: Editor ) { super( editor ); - + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions editor.config.define( 'mediaEmbed', { elementName: 'oembed', providers: [ @@ -151,26 +156,21 @@ export default class MediaEmbedEditing extends Plugin { url: /^facebook\.com/ } ] - } ); + } as MediaEmbedConfig ); - /** - * The media registry managing the media providers in the editor. - * - * @member {module:media-embed/mediaregistry~MediaRegistry} #registry - */ - this.registry = new MediaRegistry( editor.locale, editor.config.get( 'mediaEmbed' ) ); + this.registry = new MediaRegistry( editor.locale, editor.config.get( 'mediaEmbed' )! ); } /** * @inheritDoc */ - init() { + public init(): void { const editor = this.editor; const schema = editor.model.schema; const t = editor.t; const conversion = editor.conversion; const renderMediaPreview = editor.config.get( 'mediaEmbed.previewsInData' ); - const elementName = editor.config.get( 'mediaEmbed.elementName' ); + const elementName = editor.config.get( 'mediaEmbed.elementName' )!; const registry = this.registry; @@ -186,11 +186,11 @@ export default class MediaEmbedEditing extends Plugin { conversion.for( 'dataDowncast' ).elementToStructure( { model: 'media', view: ( modelElement, { writer } ) => { - const url = modelElement.getAttribute( 'url' ); + const url = modelElement.getAttribute( 'url' ) as string; return createMediaFigureElement( writer, registry, url, { elementName, - renderMediaPreview: url && renderMediaPreview + renderMediaPreview: !!url && renderMediaPreview } ); } } ); @@ -206,7 +206,7 @@ export default class MediaEmbedEditing extends Plugin { conversion.for( 'editingDowncast' ).elementToStructure( { model: 'media', view: ( modelElement, { writer } ) => { - const url = modelElement.getAttribute( 'url' ); + const url = modelElement.getAttribute( 'url' ) as string; const figure = createMediaFigureElement( writer, registry, url, { elementName, renderForEditingView: true @@ -231,11 +231,13 @@ export default class MediaEmbedEditing extends Plugin { { name: true } : null, model: ( viewMedia, { writer } ) => { - const url = viewMedia.getAttribute( 'url' ); + const url = viewMedia.getAttribute( 'url' ) as string; if ( registry.hasMedia( url ) ) { return writer.createElement( 'media', { url } ); } + + return null; } } ) // Upcast non-semantic media. @@ -247,18 +249,18 @@ export default class MediaEmbedEditing extends Plugin { } }, model: ( viewMedia, { writer } ) => { - const url = viewMedia.getAttribute( 'data-oembed-url' ); + const url = viewMedia.getAttribute( 'data-oembed-url' ) as string; if ( registry.hasMedia( url ) ) { return writer.createElement( 'media', { url } ); } + + return null; } } ) // Consume `
` elements, that were left after upcast. .add( dispatcher => { - dispatcher.on( 'element:figure', converter ); - - function converter( evt, data, conversionApi ) { + const converter: GetCallback = ( evt, data, conversionApi ) => { if ( !conversionApi.consumable.consume( data.viewItem, { name: true, classes: 'media' } ) ) { return; } @@ -268,13 +270,25 @@ export default class MediaEmbedEditing extends Plugin { data.modelRange = modelRange; data.modelCursor = modelCursor; - const modelElement = first( modelRange.getItems() ); + const modelElement = first( modelRange!.getItems() ); if ( !modelElement ) { // Revert consumed figure so other features can convert it. conversionApi.consumable.revert( data.viewItem, { name: true, classes: 'media' } ); } - } + }; + dispatcher.on( 'element:figure', converter ); } ); } } + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ MediaEmbedEditing.pluginName ]: MediaEmbedEditing; + } + + interface CommandsMap { + + mediaEmbed: MediaEmbedCommand; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.js b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts similarity index 61% rename from packages/ckeditor5-media-embed/src/mediaembedtoolbar.js rename to packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts index 0a9db20d75e..7c1250f6899 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.js +++ b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts @@ -7,7 +7,7 @@ * @module media-embed/mediaembedtoolbar */ -import { Plugin } from 'ckeditor5/src/core'; +import { Plugin, type ToolbarConfigItem, type PluginDependencies } from 'ckeditor5/src/core'; import { WidgetToolbarRepository } from 'ckeditor5/src/widget'; import { getSelectedMediaViewWidget } from './utils'; @@ -17,32 +17,29 @@ import { getSelectedMediaViewWidget } from './utils'; * * Instances of toolbar components (e.g. buttons) are created based on the * {@link module:media-embed/mediaembed~MediaEmbedConfig#toolbar `media.toolbar` configuration option}. - * - * @extends module:core/plugin~Plugin */ export default class MediaEmbedToolbar extends Plugin { /** * @inheritDoc */ - static get requires() { + public static get requires(): PluginDependencies { return [ WidgetToolbarRepository ]; } /** * @inheritDoc */ - static get pluginName() { + public static get pluginName(): 'MediaEmbedToolbar' { return 'MediaEmbedToolbar'; } /** * @inheritDoc */ - afterInit() { + public afterInit(): void { const editor = this.editor; const t = editor.t; const widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository ); - widgetToolbarRepository.register( 'mediaEmbed', { ariaLabel: t( 'Media toolbar' ), items: editor.config.get( 'mediaEmbed.toolbar' ) || [], @@ -51,11 +48,22 @@ export default class MediaEmbedToolbar extends Plugin { } } -/** - * Items to be placed in the media embed toolbar. - * This option requires adding {@link module:media-embed/mediaembedtoolbar~MediaEmbedToolbar} to the plugin list. - * - * Read more about configuring toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. - * - * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#toolbar - */ +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ MediaEmbedToolbar.pluginName ]: MediaEmbedToolbar; + } +} + +declare module './mediaembed' +{ + interface MediaEmbedConfig { + + /** + * Items to be placed in the media embed toolbar. + * This option requires adding {@link module:media-embed/mediaembedtoolbar~MediaEmbedToolbar} to the plugin list. + * + * Read more about configuring toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. + */ + toolbar?: Array; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaembedui.js b/packages/ckeditor5-media-embed/src/mediaembedui.ts similarity index 71% rename from packages/ckeditor5-media-embed/src/mediaembedui.js rename to packages/ckeditor5-media-embed/src/mediaembedui.ts index 2db166030ae..0e1380a2b2f 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedui.js +++ b/packages/ckeditor5-media-embed/src/mediaembedui.ts @@ -7,39 +7,40 @@ * @module media-embed/mediaembedui */ -import { Plugin } from 'ckeditor5/src/core'; -import { createDropdown } from 'ckeditor5/src/ui'; +import { Plugin, type PluginDependencies } from 'ckeditor5/src/core'; +import { createDropdown, CssTransitionDisablerMixin, type DropdownView } from 'ckeditor5/src/ui'; import MediaFormView from './ui/mediaformview'; import MediaEmbedEditing from './mediaembedediting'; import mediaIcon from '../theme/icons/media.svg'; +import type MediaEmbedCommand from './mediaembedcommand'; +import type { LocaleTranslate } from 'ckeditor5/src/utils'; +import type MediaRegistry from './mediaregistry'; /** * The media embed UI plugin. - * - * @extends module:core/plugin~Plugin */ export default class MediaEmbedUI extends Plugin { /** * @inheritDoc */ - static get requires() { + public static get requires(): PluginDependencies { return [ MediaEmbedEditing ]; } /** * @inheritDoc */ - static get pluginName() { + public static get pluginName(): 'MediaEmbedUI' { return 'MediaEmbedUI'; } /** * @inheritDoc */ - init() { + public init(): void { const editor = this.editor; - const command = editor.commands.get( 'mediaEmbed' ); + const command = editor.commands.get( 'mediaEmbed' )!; editor.ui.componentFactory.add( 'mediaEmbed', locale => { const dropdown = createDropdown( locale ); @@ -50,19 +51,14 @@ export default class MediaEmbedUI extends Plugin { } ); } - /** - * @private - * @param {module:ui/dropdown/dropdownview~DropdownView} dropdown - * @param {module:media-embed/mediaembedcommand~MediaEmbedCommand} command - */ - _setUpDropdown( dropdown, command ) { + private _setUpDropdown( dropdown: DropdownView, command: MediaEmbedCommand ): void { const editor = this.editor; const t = editor.t; const button = dropdown.buttonView; const registry = editor.plugins.get( MediaEmbedEditing ).registry; dropdown.once( 'change:isOpen', () => { - const form = new MediaFormView( getFormValidators( editor.t, registry ), editor.locale ); + const form = new ( CssTransitionDisablerMixin( MediaFormView ) )( getFormValidators( editor.t, registry ), editor.locale ); dropdown.panelView.children.add( form ); @@ -95,10 +91,10 @@ export default class MediaEmbedUI extends Plugin { } ); form.delegate( 'submit', 'cancel' ).to( dropdown ); - form.urlInputView.bind( 'value' ).to( command, 'value' ); + form.urlInputView.fieldView.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.urlInputView.bind( 'isEnabled' ).to( command, 'isEnabled' ); } ); dropdown.bind( 'isEnabled' ).to( command ); @@ -111,7 +107,7 @@ export default class MediaEmbedUI extends Plugin { } } -function getFormValidators( t, registry ) { +function getFormValidators( t: LocaleTranslate, registry: MediaRegistry ): Array<( v: MediaFormView ) => string | undefined> { return [ form => { if ( !form.url.length ) { @@ -125,3 +121,9 @@ function getFormValidators( t, registry ) { } ]; } + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ MediaEmbedUI.pluginName ]: MediaEmbedUI; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaregistry.js b/packages/ckeditor5-media-embed/src/mediaregistry.ts similarity index 66% rename from packages/ckeditor5-media-embed/src/mediaregistry.js rename to packages/ckeditor5-media-embed/src/mediaregistry.ts index 44818e45ca4..841e55cec7a 100644 --- a/packages/ckeditor5-media-embed/src/mediaregistry.js +++ b/packages/ckeditor5-media-embed/src/mediaregistry.ts @@ -7,10 +7,13 @@ * @module media-embed/mediaregistry */ +import type { DowncastWriter, ViewElement } from 'ckeditor5/src/engine'; import { IconView, Template } from 'ckeditor5/src/ui'; -import { logWarning, toArray } from 'ckeditor5/src/utils'; +import { type Locale, logWarning, toArray } from 'ckeditor5/src/utils'; import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg'; +import type { MediaEmbedConfig, MediaEmbedProvider } from './mediaembed'; +import type { MediaOptions } from './utils'; const mediaPlaceholderIconViewBox = '0 0 64 42'; @@ -22,14 +25,25 @@ const mediaPlaceholderIconViewBox = '0 0 64 42'; * Mostly used by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} plugin. */ export default class MediaRegistry { + /** + * The {@link module:utils/locale~Locale} instance. + */ + public locale: Locale; + + /** + * The media provider definitions available for the registry. Usually corresponding with the + * {@link module:media-embed/mediaembed~MediaEmbedConfig media configuration}. + */ + public providerDefinitions: Array; + /** * Creates an instance of the {@link module:media-embed/mediaregistry~MediaRegistry} class. * - * @param {module:utils/locale~Locale} locale The localization services instance. - * @param {module:media-embed/mediaembed~MediaEmbedConfig} config The configuration of the media embed feature. + * @param locale The localization services instance. + * @param config The configuration of the media embed feature. */ - constructor( locale, config ) { - const providers = config.providers; + constructor( locale: Locale, config: MediaEmbedConfig ) { + const providers = config.providers!; const extraProviders = config.extraProviders || []; const removedProviders = new Set( config.removeProviders ); const providerDefinitions = providers @@ -53,29 +67,16 @@ export default class MediaRegistry { return !removedProviders.has( name ); } ); - /** - * The {@link module:utils/locale~Locale} instance. - * - * @member {module:utils/locale~Locale} - */ this.locale = locale; - - /** - * The media provider definitions available for the registry. Usually corresponding with the - * {@link module:media-embed/mediaembed~MediaEmbedConfig media configuration}. - * - * @member {Array} - */ this.providerDefinitions = providerDefinitions; } /** * Checks whether the passed URL is representing a certain media type allowed in the editor. * - * @param {String} url The URL to be checked - * @returns {Boolean} + * @param url The URL to be checked */ - hasMedia( url ) { + public hasMedia( url: string ): boolean { return !!this._getMedia( url ); } @@ -85,26 +86,25 @@ export default class MediaRegistry { * * **Note:** If no URL is specified, an empty view element is returned. * - * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. - * @param {String} url The URL to be translated into a view element. - * @param {Object} options - * @param {String} [options.elementName] - * @param {Boolean} [options.renderMediaPreview] - * @param {Boolean} [options.renderForEditingView] - * @returns {module:engine/view/element~Element} + * @param writer The view writer used to produce a view element. + * @param url The URL to be translated into a view element. */ - getMediaViewElement( writer, url, options ) { - return this._getMedia( url ).getViewElement( writer, options ); + public getMediaViewElement( + writer: DowncastWriter, + url: string, + options: MediaOptions + ): ViewElement { + return this._getMedia( url )!.getViewElement( writer, options ); } /** * Returns a `Media` instance for the given URL. * * @protected - * @param {String} url The URL of the media. - * @returns {module:media-embed/mediaregistry~Media|null} The `Media` instance or `null` when there is none. + * @param url The URL of the media. + * @returns The `Media` instance or `null` when there is none. */ - _getMedia( url ) { + protected _getMedia( url: string ): Media | null { if ( !url ) { return new Media( this.locale ); } @@ -131,11 +131,10 @@ export default class MediaRegistry { * Tries to match `url` to `pattern`. * * @private - * @param {String} url The URL of the media. - * @param {RegExp} pattern The pattern that should accept the media URL. - * @returns {Array|null} + * @param url The URL of the media. + * @param pattern The pattern that should accept the media URL. */ - _getUrlMatches( url, pattern ) { + private _getUrlMatches( url: string, pattern: RegExp ): RegExpMatchArray | null { // 1. Try to match without stripping the protocol and "www" subdomain. let match = url.match( pattern ); @@ -171,49 +170,46 @@ export default class MediaRegistry { * @private */ class Media { - constructor( locale, url, match, previewRenderer ) { - /** - * The URL this Media instance represents. - * - * @member {String} - */ - this.url = this._getValidUrl( url ); + /** + * The URL this Media instance represents. + */ + public url: string | null; - /** - * Shorthand for {@link module:utils/locale~Locale#t}. - * - * @see module:utils/locale~Locale#t - * @method - */ - this._locale = locale; + /** + * Shorthand for {@link module:utils/locale~Locale#t}. + * + * @see module:utils/locale~Locale#t + * @method + */ + private _locale: Locale; - /** - * The output of the `RegExp.match` which validated the {@link #url} of this media. - * - * @member {Object} - */ - this._match = match; + /** + * The output of the `RegExp.match` which validated the {@link #url} of this media. + */ + private _match?: object; + + /** + * The function returning the HTML string preview of this media. + */ + private _previewRenderer?: Function; - /** - * The function returning the HTML string preview of this media. - * - * @member {Function} - */ + constructor( locale: Locale, url?: string, match?: object, previewRenderer?: Function ) { + this.url = this._getValidUrl( url ); + this._locale = locale; + this._match = match; this._previewRenderer = previewRenderer; } /** * Returns the view element representation of the media. * - * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. - * @param {Object} options - * @param {String} [options.elementName] - * @param {Boolean} [options.renderMediaPreview] - * @param {Boolean} [options.renderForEditingView] - * @returns {module:engine/view/element~Element} + * @param writer The view writer used to produce a view element. */ - getViewElement( writer, options ) { - const attributes = {}; + public getViewElement( + writer: DowncastWriter, + options: MediaOptions + ): ViewElement { + const attributes: Record = {}; let viewElement; if ( options.renderForEditingView || ( options.renderMediaPreview && this.url && this._previewRenderer ) ) { @@ -228,7 +224,7 @@ class Media { const mediaHtml = this._getPreviewHtml( options ); viewElement = writer.createRawElement( 'div', attributes, ( domElement, domConverter ) => { - domConverter.setContentOf( domElement, mediaHtml ); + domConverter!.setContentOf( domElement, mediaHtml ); } ); } else { if ( this.url ) { @@ -245,13 +241,8 @@ class Media { /** * Returns the HTML string of the media content preview. - * - * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. - * @param {Object} options - * @param {Boolean} [options.renderForEditingView] - * @returns {String} */ - _getPreviewHtml( options ) { + private _getPreviewHtml( options: { renderForEditingView?: boolean } ): string { if ( this._previewRenderer ) { return this._previewRenderer( this._match ); } else { @@ -267,10 +258,8 @@ class Media { /** * Returns the placeholder HTML when the media has no content preview. - * - * @returns {String} */ - _getPlaceholderHtml() { + private _getPlaceholderHtml(): string { const icon = new IconView(); const t = this._locale.t; @@ -310,7 +299,7 @@ class Media { ] } ] - } ).render(); + } ).render() as HTMLElement; return placeholder.outerHTML; } @@ -318,10 +307,9 @@ class Media { /** * Returns the full URL to the specified media. * - * @param {String} url The URL of the media. - * @returns {String|null} + * @param url The URL of the media. */ - _getValidUrl( url ) { + private _getValidUrl( url: string | undefined ): string | null { if ( !url ) { return null; } diff --git a/packages/ckeditor5-media-embed/src/ui/mediaformview.js b/packages/ckeditor5-media-embed/src/ui/mediaformview.ts similarity index 63% rename from packages/ckeditor5-media-embed/src/ui/mediaformview.js rename to packages/ckeditor5-media-embed/src/ui/mediaformview.ts index 9cc337a9ac8..2aa9d8a6209 100644 --- a/packages/ckeditor5-media-embed/src/ui/mediaformview.js +++ b/packages/ckeditor5-media-embed/src/ui/mediaformview.ts @@ -8,16 +8,16 @@ */ import { + type InputTextView, ButtonView, FocusCycler, LabeledFieldView, View, ViewCollection, createLabeledInputText, - injectCssTransitionDisabler, submitHandler } from 'ckeditor5/src/ui'; -import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils'; +import { FocusTracker, KeystrokeHandler, type Locale } from 'ckeditor5/src/utils'; import { icons } from 'ckeditor5/src/core'; // See: #8833. @@ -29,82 +29,90 @@ 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.} validators Form validators used by {@link #isValid}. - * @param {module:utils/locale~Locale} [locale] The localization services instance. + * Tracks information about the DOM focus in the form. + * + * @readonly + */ + public readonly focusTracker: FocusTracker; + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + * + * @readonly + */ + public readonly keystrokes: KeystrokeHandler; + + /** + * The value of the URL input. + */ + declare public mediaURLInputValue: string; + + /** + * The URL input view. + */ + public urlInputView: LabeledFieldView; + + /** + * The Save button view. + */ + public saveButtonView: ButtonView; + + /** + * The Cancel button view. + */ + public cancelButtonView: ButtonView; + + /** + * A collection of views that can be focused in the form. */ - constructor( validators, locale ) { + protected readonly _focusables: ViewCollection; + + /** + * Helps cycling over {@link #_focusables} in the form. + */ + protected readonly _focusCycler: FocusCycler; + + /** + * An array of form validators used by {@link #isValid}. + */ + protected readonly _validators: Array<( v: MediaFormView ) => string | undefined>; + + /** + * The default info text for the {@link #urlInputView}. + */ + private _urlInputViewInfoDefault?: string; + + /** + * The info text with an additional tip for the {@link #urlInputView}, + * displayed when the input has some value. + */ + private _urlInputViewInfoTip?: string; + + /** + * @param validators Form validators used by {@link #isValid}. + * @param locale The localization services instance. + */ + constructor( validators: Array<( v: MediaFormView ) => string | undefined>, locale: Locale ) { super( locale ); const t = locale.t; - /** - * Tracks information about the 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 value of the URL input. - * - * @member {String} #mediaURLInputValue - * @observable - */ this.set( 'mediaURLInputValue', '' ); - - /** - * The URL input view. - * - * @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView} - */ this.urlInputView = this._createUrlInput(); - /** - * The Save button view. - * - * @member {module:ui/button/buttonview~ButtonView} - */ this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); this.saveButtonView.type = 'submit'; this.saveButtonView.bind( 'isEnabled' ).to( this, 'mediaURLInputValue', value => !!value ); - /** - * The Cancel button view. - * - * @member {module:ui/button/buttonview~ButtonView} - */ this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); - /** - * A collection of views that 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, @@ -118,13 +126,6 @@ export default class MediaFormView extends View { } } ); - /** - * An array of form validators used by {@link #isValid}. - * - * @readonly - * @protected - * @member {Array.} - */ this._validators = validators; this.setTemplate( { @@ -146,30 +147,12 @@ export default class MediaFormView extends View { this.cancelButtonView ] } ); - - // When rewriting to typescript try to change it to CssTransitionDisablerMixin. - injectCssTransitionDisabler( this ); - - /** - * The default info text for the {@link #urlInputView}. - * - * @private - * @member {String} #_urlInputViewInfoDefault - */ - - /** - * The info text with an additional tip for the {@link #urlInputView}, - * displayed when the input has some value. - * - * @private - * @member {String} #_urlInputViewInfoTip - */ } /** * @inheritDoc */ - render() { + public override render(): void { super.render(); submitHandler( { @@ -187,13 +170,13 @@ export default class MediaFormView extends View { this._focusables.add( v ); // Register the view in the focus tracker. - this.focusTracker.add( v.element ); + this.focusTracker.add( v.element! ); } ); // Start listening for the keystrokes coming from #element. - this.keystrokes.listenTo( this.element ); + this.keystrokes.listenTo( this.element! ); - const stopPropagation = data => data.stopPropagation(); + const stopPropagation = ( data: KeyboardEvent ) => 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 @@ -206,7 +189,7 @@ export default class MediaFormView extends View { // Intercept the `selectstart` event, which is blocked by default because of the default behavior // of the DropdownView#panelView. // TODO: blocking `selectstart` in the #panelView should be configurable per–drop–down instance. - this.listenTo( this.urlInputView.element, 'selectstart', ( evt, domEvt ) => { + this.listenTo( this.urlInputView.element!, 'selectstart', ( evt, domEvt ) => { domEvt.stopPropagation(); }, { priority: 'high' } ); } @@ -214,7 +197,7 @@ export default class MediaFormView extends View { /** * @inheritDoc */ - destroy() { + public override destroy(): void { super.destroy(); this.focusTracker.destroy(); @@ -224,7 +207,7 @@ export default class MediaFormView extends View { /** * Focuses the fist {@link #_focusables} in the form. */ - focus() { + public focus(): void { this._focusCycler.focusFirst(); } @@ -233,23 +216,19 @@ export default class MediaFormView extends View { * * **Note**: Do not confuse it 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 the DOM. - * - * @type {String} */ - get url() { - return this.urlInputView.fieldView.element.value.trim(); + public get url(): string { + return this.urlInputView.fieldView.element!.value.trim(); } - set url( url ) { - this.urlInputView.fieldView.element.value = url.trim(); + public set url( url: string ) { + this.urlInputView.fieldView.element!.value = url.trim(); } /** * Validates the form and returns `false` when some fields are invalid. - * - * @returns {Boolean} */ - isValid() { + public isValid(): boolean { this.resetFormStatus(); for ( const validator of this._validators ) { @@ -273,19 +252,18 @@ export default class MediaFormView extends View { * * See {@link #isValid}. */ - resetFormStatus() { + public resetFormStatus(): void { this.urlInputView.errorText = null; - this.urlInputView.infoText = this._urlInputViewInfoDefault; + this.urlInputView.infoText = this._urlInputViewInfoDefault!; } /** * Creates a labeled input view. * - * @private - * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView} Labeled input view instance. + * @returns Labeled input view instance. */ - _createUrlInput() { - const t = this.locale.t; + private _createUrlInput(): LabeledFieldView { + const t = this.locale!.t; const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); const inputField = labeledInput.fieldView; @@ -298,8 +276,8 @@ export default class MediaFormView extends View { inputField.on( 'input', () => { // Display the tip text only when there is some value. Otherwise fall back to the default info text. - labeledInput.infoText = inputField.element.value ? this._urlInputViewInfoTip : this._urlInputViewInfoDefault; - this.mediaURLInputValue = inputField.element.value.trim(); + labeledInput.infoText = inputField.element!.value ? this._urlInputViewInfoTip! : this._urlInputViewInfoDefault!; + this.mediaURLInputValue = inputField.element!.value.trim(); } ); return labeledInput; @@ -308,14 +286,13 @@ export default class MediaFormView extends View { /** * Creates a button view. * - * @private - * @param {String} label The button label. - * @param {String} icon The button 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. + * @param label The button label. + * @param icon The button icon. + * @param className The additional button CSS class name. + * @param eventName An event name that the `ButtonView#execute` event will be delegated to. + * @returns The button view instance. */ - _createButton( label, icon, className, eventName ) { + private _createButton( label: string, icon: string, className: string, eventName?: string ): ButtonView { const button = new ButtonView( this.locale ); button.set( { @@ -342,11 +319,11 @@ export default class MediaFormView extends View { * Fired when the form view is submitted (when one of the children triggered the submit event), * e.g. click on {@link #saveButtonView}. * - * @event submit + * @eventName submit */ /** * Fired when the form view is canceled, e.g. by a click on {@link #cancelButtonView}. * - * @event cancel + * @eventName cancel */ diff --git a/packages/ckeditor5-media-embed/src/utils.js b/packages/ckeditor5-media-embed/src/utils.js deleted file mode 100644 index ac3518560ca..00000000000 --- a/packages/ckeditor5-media-embed/src/utils.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module media-embed/utils - */ - -import { isWidget, toWidget } from 'ckeditor5/src/widget'; - -/** - * 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/downcastwriter~DowncastWriter} 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( 'media', true, viewElement ); - - return toWidget( viewElement, writer, { label } ); -} - -/** - * Returns a media widget editing view element if one is selected. - * - * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection - * @returns {module:engine/view/element~Element|null} - */ -export function getSelectedMediaViewWidget( selection ) { - const viewElement = selection.getSelectedElement(); - - if ( viewElement && isMediaWidget( viewElement ) ) { - return viewElement; - } - - return null; -} - -/** - * Checks if a given view element is a media widget. - * - * @param {module:engine/view/element~Element} viewElement - * @returns {Boolean} - */ -export function isMediaWidget( viewElement ) { - return !!viewElement.getCustomProperty( 'media' ) && isWidget( viewElement ); -} - -/** - * Creates a view element representing the media. Either a "semantic" one for the data pipeline: - * - *
- * - *
- * - * or a "non-semantic" (for the editing view pipeline): - * - *
- *
[ non-semantic media preview for "foo" ]
- *
- * - * @param {module:engine/view/downcastwriter~DowncastWriter} writer - * @param {module:media-embed/mediaregistry~MediaRegistry} registry - * @param {String} url - * @param {Object} options - * @param {String} [options.elementName] - * @param {Boolean} [options.useSemanticWrapper] - * @param {Boolean} [options.renderForEditingView] - * @returns {module:engine/view/containerelement~ContainerElement} - */ -export function createMediaFigureElement( writer, registry, url, options ) { - return writer.createContainerElement( 'figure', { class: 'media' }, [ - registry.getMediaViewElement( writer, url, options ), - writer.createSlot() - ] ); -} - -/** - * Returns a selected media element in the model, if any. - * - * @param {module:engine/model/selection~Selection} selection - * @returns {module:engine/model/element~Element|null} - */ -export function getSelectedMediaModelWidget( selection ) { - const selectedElement = selection.getSelectedElement(); - - if ( selectedElement && selectedElement.is( 'element', 'media' ) ) { - return selectedElement; - } - - return null; -} - -/** - * Creates a media element and inserts it into the model. - * - * **Note**: This method will use {@link module:engine/model/model~Model#insertContent `model.insertContent()`} logic of inserting content - * if no `insertPosition` is passed. - * - * @param {module:engine/model/model~Model} model - * @param {String} url An URL of an embeddable media. - * @param {module:engine/model/range~Range} [insertRange] The range to insert the media. If not specified, - * the default behavior of {@link module:engine/model/model~Model#insertContent `model.insertContent()`} will - * be applied. - * @param {Boolean} findOptimalPosition If true it will try to find optimal position to insert media without breaking content - * in which a selection is. - */ -export function insertMedia( model, url, selectable, findOptimalPosition ) { - model.change( writer => { - const mediaElement = writer.createElement( 'media', { url } ); - - model.insertObject( mediaElement, selectable, null, { - setSelection: 'on', - findOptimalPosition - } ); - } ); -} diff --git a/packages/ckeditor5-media-embed/src/utils.ts b/packages/ckeditor5-media-embed/src/utils.ts new file mode 100644 index 00000000000..a57160dfe5a --- /dev/null +++ b/packages/ckeditor5-media-embed/src/utils.ts @@ -0,0 +1,125 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/utils + */ + +import type { + ViewContainerElement, + Element, + Model, + Selectable, + Selection, + DowncastWriter, + ViewDocumentSelection, + ViewElement, + DocumentSelection +} from 'ckeditor5/src/engine'; +import { isWidget, toWidget } from 'ckeditor5/src/widget'; +import type MediaRegistry from './mediaregistry'; + +/** + * 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 writer An instance of the view writer. + * @param label The element's label. + */ +export function toMediaWidget( viewElement: ViewElement, writer: DowncastWriter, label: string ): ViewElement { + writer.setCustomProperty( 'media', true, viewElement ); + + return toWidget( viewElement, writer, { label } ); +} + +/** + * Returns a media widget editing view element if one is selected. + */ +export function getSelectedMediaViewWidget( selection: ViewDocumentSelection ): ViewElement | null { + const viewElement = selection.getSelectedElement(); + + if ( viewElement && isMediaWidget( viewElement ) ) { + return viewElement; + } + + return null; +} + +/** + * Checks if a given view element is a media widget. + */ +export function isMediaWidget( viewElement: ViewElement ): boolean { + return !!viewElement.getCustomProperty( 'media' ) && isWidget( viewElement ); +} + +/** + * Creates a view element representing the media. Either a "semantic" one for the data pipeline: + * + * ```html + *
+ * + *
+ * ``` + * + * or a "non-semantic" (for the editing view pipeline): + * + * ```html + *
+ *
[ non-semantic media preview for "foo" ]
+ *
+ * ``` + */ +export function createMediaFigureElement( + writer: DowncastWriter, + registry: MediaRegistry, + url: string, + options: MediaOptions +): ViewContainerElement { + return writer.createContainerElement( 'figure', { class: 'media' }, [ + registry.getMediaViewElement( writer, url, options ), + writer.createSlot( 'children' ) + ] ); +} + +/** + * Returns a selected media element in the model, if any. + */ +export function getSelectedMediaModelWidget( selection: Selection | DocumentSelection ): Element | null { + const selectedElement = selection.getSelectedElement(); + + if ( selectedElement && selectedElement.is( 'element', 'media' ) ) { + return selectedElement; + } + + return null; +} + +/** + * Creates a media element and inserts it into the model. + * + * **Note**: This method will use {@link module:engine/model/model~Model#insertContent `model.insertContent()`} logic of inserting content + * if no `insertPosition` is passed. + * + * @param url An URL of an embeddable media. + * @param findOptimalPosition If true it will try to find optimal position to insert media without breaking content + * in which a selection is. + */ +export function insertMedia( model: Model, url: string, selectable: Selectable, findOptimalPosition: boolean ): void { + model.change( writer => { + const mediaElement = writer.createElement( 'media', { url } ); + + model.insertObject( mediaElement, selectable, null, { + setSelection: 'on', + findOptimalPosition: findOptimalPosition ? 'auto' : undefined + } ); + } ); +} + +export type MediaOptions = { + elementName: string; + renderMediaPreview?: boolean; + renderForEditingView?: boolean; +}; diff --git a/packages/ckeditor5-media-embed/tests/insertmediacommand.js b/packages/ckeditor5-media-embed/tests/insertmediacommand.js index 1685685ae05..17f636df882 100644 --- a/packages/ckeditor5-media-embed/tests/insertmediacommand.js +++ b/packages/ckeditor5-media-embed/tests/insertmediacommand.js @@ -106,7 +106,7 @@ describe( 'MediaEmbedCommand', () => { describe( 'value', () => { it( 'should be null when no media is selected (paragraph)', () => { setData( model, '

foo[]

' ); - expect( command.value ).to.be.null; + expect( command.value ).to.be.undefined; } ); it( 'should equal the url of the selected media', () => { diff --git a/packages/ckeditor5-media-embed/tests/mediaembedui.js b/packages/ckeditor5-media-embed/tests/mediaembedui.js index 6d391d2c60e..05239e734c7 100644 --- a/packages/ckeditor5-media-embed/tests/mediaembedui.js +++ b/packages/ckeditor5-media-embed/tests/mediaembedui.js @@ -224,13 +224,13 @@ describe( 'MediaEmbedUI', () => { form.fire( 'submit' ); } ); - it( 'binds urlInputView#isReadOnly to command#isEnabled', () => { + it( 'binds urlInputView#isEnabled to command#isEnabled', () => { const command = editor.commands.get( 'mediaEmbed' ); - expect( form.urlInputView.isReadOnly ).to.be.false; + expect( form.urlInputView.isEnabled ).to.be.true; command.isEnabled = false; - expect( form.urlInputView.isReadOnly ).to.be.true; + expect( form.urlInputView.isEnabled ).to.be.false; } ); it( 'should trim URL input value', () => { @@ -256,6 +256,10 @@ describe( 'MediaEmbedUI', () => { expect( form.saveButtonView.isEnabled ).to.be.true; } ); + it( 'should implement the CSS transition disabling feature', () => { + expect( form.disableCssTransitions ).to.be.a( 'function' ); + } ); + describe( 'validators', () => { it( 'check the empty URL', () => { form.url = ''; diff --git a/packages/ckeditor5-media-embed/tests/ui/mediaformview.js b/packages/ckeditor5-media-embed/tests/ui/mediaformview.js index c3d1491f5ec..f75dd645982 100644 --- a/packages/ckeditor5-media-embed/tests/ui/mediaformview.js +++ b/packages/ckeditor5-media-embed/tests/ui/mediaformview.js @@ -84,10 +84,6 @@ describe( 'MediaFormView', () => { expect( spy.calledOnce ).to.true; } ); - it( 'should implement the CSS transition disabling feature', () => { - expect( view.disableCssTransitions ).to.be.a( 'function' ); - } ); - describe( 'url input view', () => { it( 'has info text', () => { expect( view.urlInputView.infoText ).to.match( /^Paste the media URL/ ); diff --git a/packages/ckeditor5-media-embed/tsconfig.json b/packages/ckeditor5-media-embed/tsconfig.json new file mode 100644 index 00000000000..9d4c891939c --- /dev/null +++ b/packages/ckeditor5-media-embed/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "ckeditor5/tsconfig.json", + "include": [ + "src", + "../../typings" + ] +} diff --git a/packages/ckeditor5-media-embed/tsconfig.release.json b/packages/ckeditor5-media-embed/tsconfig.release.json new file mode 100644 index 00000000000..17d2102b311 --- /dev/null +++ b/packages/ckeditor5-media-embed/tsconfig.release.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.release.json", + "include": [ + "./src/", + ], + "exclude": [ + "./tests/" + ] +} diff --git a/packages/ckeditor5-widget/src/widgettoolbarrepository.ts b/packages/ckeditor5-widget/src/widgettoolbarrepository.ts index 2f89a917172..a21192bb509 100644 --- a/packages/ckeditor5-widget/src/widgettoolbarrepository.ts +++ b/packages/ckeditor5-widget/src/widgettoolbarrepository.ts @@ -148,7 +148,7 @@ export default class WidgetToolbarRepository extends Plugin { { ariaLabel, items, getRelatedElement, balloonClassName = 'ck-toolbar-container' }: { ariaLabel?: string; items: Array; - getRelatedElement: ( selection: ViewDocumentSelection ) => ViewElement; + getRelatedElement: ( selection: ViewDocumentSelection ) => ViewElement | null; balloonClassName?: string; } ): void { From 66742e359d8f9a4718ad0538a3d107dfec486cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Remiszewski?= Date: Fri, 27 Jan 2023 15:38:25 +0100 Subject: [PATCH 2/8] Change accessibility modifiers, improve docs. --- .../ckeditor5-media-embed/src/automediaembed.ts | 8 +++++++- packages/ckeditor5-media-embed/src/converters.ts | 8 ++++---- packages/ckeditor5-media-embed/src/mediaembed.ts | 4 ++-- .../src/mediaembedcommand.ts | 2 +- .../src/mediaembedediting.ts | 6 +++--- .../src/mediaembedtoolbar.ts | 3 +-- .../ckeditor5-media-embed/src/mediaregistry.ts | 16 ++++++---------- .../src/ui/mediaformview.ts | 6 +++--- packages/ckeditor5-media-embed/src/utils.ts | 3 +++ 9 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/ckeditor5-media-embed/src/automediaembed.ts b/packages/ckeditor5-media-embed/src/automediaembed.ts index 3ebe0550305..b579c251ff7 100644 --- a/packages/ckeditor5-media-embed/src/automediaembed.ts +++ b/packages/ckeditor5-media-embed/src/automediaembed.ts @@ -105,7 +105,7 @@ export default class AutoMediaEmbed extends Plugin { * @param leftPosition Left position of the selection. * @param rightPosition Right position of the selection. */ - protected _embedMediaBetweenPositions( leftPosition: LivePosition, rightPosition: LivePosition ): void { + private _embedMediaBetweenPositions( leftPosition: LivePosition, rightPosition: LivePosition ): void { const editor = this.editor; const mediaRegistry = editor.plugins.get( MediaEmbedEditing ).registry; // TODO: Use marker instead of LiveRange & LivePositions. @@ -174,3 +174,9 @@ export default class AutoMediaEmbed extends Plugin { }, 100 ); } } + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ AutoMediaEmbed.pluginName ]: AutoMediaEmbed; + } +} diff --git a/packages/ckeditor5-media-embed/src/converters.ts b/packages/ckeditor5-media-embed/src/converters.ts index 13026c8af61..7b2b4741b83 100644 --- a/packages/ckeditor5-media-embed/src/converters.ts +++ b/packages/ckeditor5-media-embed/src/converters.ts @@ -3,15 +3,15 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +/** + * @module media-embed/converters + */ + import type { GetCallback } from 'ckeditor5/src/utils'; import type { DowncastAttributeEvent, DowncastDispatcher, Element, ViewElement } from 'ckeditor5/src/engine'; import type MediaRegistry from './mediaregistry'; import type { MediaOptions } from './utils'; -/** - * @module media-embed/converters - */ - /** * Returns a function that converts the model "url" attribute to the view representation. * diff --git a/packages/ckeditor5-media-embed/src/mediaembed.ts b/packages/ckeditor5-media-embed/src/mediaembed.ts index 5d0018cc360..fc0321bf5ca 100644 --- a/packages/ckeditor5-media-embed/src/mediaembed.ts +++ b/packages/ckeditor5-media-embed/src/mediaembed.ts @@ -9,13 +9,13 @@ import { Plugin, type PluginDependencies } from 'ckeditor5/src/core'; import { Widget } from 'ckeditor5/src/widget'; +import type { ArrayOrItem } from 'ckeditor5/src/utils'; import MediaEmbedEditing from './mediaembedediting'; import AutoMediaEmbed from './automediaembed'; import MediaEmbedUI from './mediaembedui'; import '../theme/mediaembed.css'; -import type { ArrayOrItem } from 'ckeditor5/src/utils'; /** * The media embed plugin. @@ -123,7 +123,7 @@ export interface MediaEmbedProvider { * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} * is `true`, the rendering function **will always** be used for the media in the editor data output. */ - html?: ( match: RegExpExecArray ) => string; + html?: ( match: RegExpMatchArray ) => string; } /** diff --git a/packages/ckeditor5-media-embed/src/mediaembedcommand.ts b/packages/ckeditor5-media-embed/src/mediaembedcommand.ts index c745b8cc345..eb93ff64de6 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedcommand.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedcommand.ts @@ -68,7 +68,7 @@ export default class MediaEmbedCommand extends Command { } /** - * Checks if the table is allowed in the parent. + * Checks if the media embed is allowed in the parent. */ function isAllowedInParent( selection: Selection | DocumentSelection, model: Model ): boolean { const insertionRange = findOptimalInsertionRange( selection, model ); diff --git a/packages/ckeditor5-media-embed/src/mediaembedediting.ts b/packages/ckeditor5-media-embed/src/mediaembedediting.ts index bdc2870f8b0..f42c6e2a987 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedediting.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedediting.ts @@ -8,16 +8,16 @@ */ import { Plugin, type Editor } from 'ckeditor5/src/core'; +import type { UpcastElementEvent } from 'ckeditor5/src/engine'; import { first, type GetCallback } from 'ckeditor5/src/utils'; import { modelToViewUrlAttributeConverter } from './converters'; +import type { MediaEmbedConfig } from './mediaembed'; import MediaEmbedCommand from './mediaembedcommand'; import MediaRegistry from './mediaregistry'; import { toMediaWidget, createMediaFigureElement } from './utils'; import '../theme/mediaembedediting.css'; -import type { MediaEmbedConfig } from './mediaembed'; -import type { UpcastElementEvent } from 'ckeditor5/src/engine'; /** * The media embed editing feature. @@ -40,7 +40,6 @@ export default class MediaEmbedEditing extends Plugin { */ constructor( editor: Editor ) { super( editor ); - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions editor.config.define( 'mediaEmbed', { elementName: 'oembed', providers: [ @@ -277,6 +276,7 @@ export default class MediaEmbedEditing extends Plugin { conversionApi.consumable.revert( data.viewItem, { name: true, classes: 'media' } ); } }; + dispatcher.on( 'element:figure', converter ); } ); } diff --git a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts index 7c1250f6899..7b2e3c84ace 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts @@ -54,8 +54,7 @@ declare module '@ckeditor/ckeditor5-core' { } } -declare module './mediaembed' -{ +declare module './mediaembed' { interface MediaEmbedConfig { /** diff --git a/packages/ckeditor5-media-embed/src/mediaregistry.ts b/packages/ckeditor5-media-embed/src/mediaregistry.ts index 841e55cec7a..3e9e12a0873 100644 --- a/packages/ckeditor5-media-embed/src/mediaregistry.ts +++ b/packages/ckeditor5-media-embed/src/mediaregistry.ts @@ -100,11 +100,10 @@ export default class MediaRegistry { /** * Returns a `Media` instance for the given URL. * - * @protected * @param url The URL of the media. * @returns The `Media` instance or `null` when there is none. */ - protected _getMedia( url: string ): Media | null { + private _getMedia( url: string ): Media | null { if ( !url ) { return new Media( this.locale ); } @@ -112,7 +111,7 @@ export default class MediaRegistry { url = url.trim(); for ( const definition of this.providerDefinitions ) { - const previewRenderer = definition.html; + const previewRenderer = definition.html!; const pattern = toArray( definition.url ); for ( const subPattern of pattern ) { @@ -130,7 +129,6 @@ export default class MediaRegistry { /** * Tries to match `url` to `pattern`. * - * @private * @param url The URL of the media. * @param pattern The pattern that should accept the media URL. */ @@ -166,8 +164,6 @@ export default class MediaRegistry { * Represents media defined by the provider configuration. * * It can be rendered to the {@link module:engine/view/element~Element view element} and used in the editing or data pipeline. - * - * @private */ class Media { /** @@ -186,14 +182,14 @@ class Media { /** * The output of the `RegExp.match` which validated the {@link #url} of this media. */ - private _match?: object; + private _match?: RegExpMatchArray; /** * The function returning the HTML string preview of this media. */ - private _previewRenderer?: Function; + private _previewRenderer?: ( match: RegExpMatchArray ) => string; - constructor( locale: Locale, url?: string, match?: object, previewRenderer?: Function ) { + constructor( locale: Locale, url?: string, match?: RegExpMatchArray, previewRenderer?: ( match: RegExpMatchArray ) => string ) { this.url = this._getValidUrl( url ); this._locale = locale; this._match = match; @@ -244,7 +240,7 @@ class Media { */ private _getPreviewHtml( options: { renderForEditingView?: boolean } ): string { if ( this._previewRenderer ) { - return this._previewRenderer( this._match ); + return this._previewRenderer( this._match! ); } else { // The placeholder only makes sense for editing view and media which have URLs. // Placeholder is never displayed in data and URL-less media have no content. diff --git a/packages/ckeditor5-media-embed/src/ui/mediaformview.ts b/packages/ckeditor5-media-embed/src/ui/mediaformview.ts index 2aa9d8a6209..bf5137a5bfc 100644 --- a/packages/ckeditor5-media-embed/src/ui/mediaformview.ts +++ b/packages/ckeditor5-media-embed/src/ui/mediaformview.ts @@ -68,17 +68,17 @@ export default class MediaFormView extends View { /** * A collection of views that can be focused in the form. */ - protected readonly _focusables: ViewCollection; + private readonly _focusables: ViewCollection; /** * Helps cycling over {@link #_focusables} in the form. */ - protected readonly _focusCycler: FocusCycler; + private readonly _focusCycler: FocusCycler; /** * An array of form validators used by {@link #isValid}. */ - protected readonly _validators: Array<( v: MediaFormView ) => string | undefined>; + private readonly _validators: Array<( v: MediaFormView ) => string | undefined>; /** * The default info text for the {@link #urlInputView}. diff --git a/packages/ckeditor5-media-embed/src/utils.ts b/packages/ckeditor5-media-embed/src/utils.ts index a57160dfe5a..201f950284f 100644 --- a/packages/ckeditor5-media-embed/src/utils.ts +++ b/packages/ckeditor5-media-embed/src/utils.ts @@ -118,6 +118,9 @@ export function insertMedia( model: Model, url: string, selectable: Selectable, } ); } +/** + * Type for commonly grouped function parameters across this package. + */ export type MediaOptions = { elementName: string; renderMediaPreview?: boolean; From b47444aaef4f5202884c2d4ebf831834a8b1a6b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Remiszewski?= Date: Mon, 6 Feb 2023 09:10:31 +0100 Subject: [PATCH 3/8] Update media-embed to engine changes. --- .gitignore | 1 + packages/ckeditor5-media-embed/src/utils.ts | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5481669d441..ea45db811c7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ packages/ckeditor5-indent/src/**/*.js packages/ckeditor5-language/src/**/*.js packages/ckeditor5-list/src/**/*.js packages/ckeditor5-markdown-gfm/src/**/*.js +packages/ckeditor5-media-embed/src/**/*.js packages/ckeditor5-mention/src/**/*.js packages/ckeditor5-minimap/src/**/*.js packages/ckeditor5-page-break/src/**/*.js diff --git a/packages/ckeditor5-media-embed/src/utils.ts b/packages/ckeditor5-media-embed/src/utils.ts index 201f950284f..672725793e6 100644 --- a/packages/ckeditor5-media-embed/src/utils.ts +++ b/packages/ckeditor5-media-embed/src/utils.ts @@ -16,7 +16,8 @@ import type { DowncastWriter, ViewDocumentSelection, ViewElement, - DocumentSelection + DocumentSelection, + Node } from 'ckeditor5/src/engine'; import { isWidget, toWidget } from 'ckeditor5/src/widget'; import type MediaRegistry from './mediaregistry'; @@ -80,7 +81,7 @@ export function createMediaFigureElement( ): ViewContainerElement { return writer.createContainerElement( 'figure', { class: 'media' }, [ registry.getMediaViewElement( writer, url, options ), - writer.createSlot( 'children' ) + writer.createSlot() ] ); } @@ -107,7 +108,7 @@ export function getSelectedMediaModelWidget( selection: Selection | DocumentSele * @param findOptimalPosition If true it will try to find optimal position to insert media without breaking content * in which a selection is. */ -export function insertMedia( model: Model, url: string, selectable: Selectable, findOptimalPosition: boolean ): void { +export function insertMedia( model: Model, url: string, selectable: Exclude, findOptimalPosition: boolean ): void { model.change( writer => { const mediaElement = writer.createElement( 'media', { url } ); From 7a61f5cd955e72e9888b6e42b0954b6319673c81 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 6 Feb 2023 12:12:16 +0100 Subject: [PATCH 4/8] Simplified insertObject and insertContent signature. --- packages/ckeditor5-engine/src/model/model.ts | 33 -------------------- packages/ckeditor5-media-embed/src/utils.ts | 2 +- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/packages/ckeditor5-engine/src/model/model.ts b/packages/ckeditor5-engine/src/model/model.ts index 9d60a8fca6c..c4c843de883 100644 --- a/packages/ckeditor5-engine/src/model/model.ts +++ b/packages/ckeditor5-engine/src/model/model.ts @@ -381,18 +381,6 @@ export default class Model extends ObservableMixin() { // @if CK_DEBUG_ENGINE // return new OperationReplayer( this, '-------', stringifiedOperations ); // @if CK_DEBUG_ENGINE // } - public insertContent( - content: Item | ModelDocumentFragment, - selectable: Node, - placeOrOffset: PlaceOrOffset, - ...rest: Array - ): ModelRange; - public insertContent( - content: Item | ModelDocumentFragment, - selectable?: Exclude, - ...rest: Array - ): ModelRange; - /** * Inserts content at the position in the editor specified by the selection, as one would expect the paste * functionality to work. @@ -547,27 +535,6 @@ export default class Model extends ObservableMixin() { return this.fire( 'insertContent', [ content, selection, placeOrOffset, ...rest ] )!; } - public insertObject( - element: ModelElement, - selectable: Node, - placeOrOffset: PlaceOrOffset, - options?: { - findOptimalPosition?: 'auto' | 'before' | 'after'; - setSelection?: 'on' | 'after'; - }, - ...rest: Array - ): ModelRange; - public insertObject( - element: ModelElement, - selectable?: Exclude, - placeOrOffset?: null, - options?: { - findOptimalPosition?: 'auto' | 'before' | 'after'; - setSelection?: 'on' | 'after'; - }, - ...rest: Array - ): ModelRange; - /** * Inserts an {@glink framework/guides/deep-dive/schema#object-elements object element} at a specific position in the editor content. * diff --git a/packages/ckeditor5-media-embed/src/utils.ts b/packages/ckeditor5-media-embed/src/utils.ts index 672725793e6..73e78934cb5 100644 --- a/packages/ckeditor5-media-embed/src/utils.ts +++ b/packages/ckeditor5-media-embed/src/utils.ts @@ -108,7 +108,7 @@ export function getSelectedMediaModelWidget( selection: Selection | DocumentSele * @param findOptimalPosition If true it will try to find optimal position to insert media without breaking content * in which a selection is. */ -export function insertMedia( model: Model, url: string, selectable: Exclude, findOptimalPosition: boolean ): void { +export function insertMedia( model: Model, url: string, selectable: Selectable, findOptimalPosition: boolean ): void { model.change( writer => { const mediaElement = writer.createElement( 'media', { url } ); From d3cc5b80f1ac6ff3cff6fc4b23022ae4d95ec5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Remiszewski?= Date: Thu, 9 Feb 2023 11:39:15 +0100 Subject: [PATCH 5/8] Add missing imports to prevent CI failures.. --- .../ckeditor5-media-embed/src/automediaembed.ts | 15 +++++++++------ packages/ckeditor5-media-embed/src/utils.ts | 3 +-- packages/ckeditor5-undo/src/index.ts | 1 + 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/ckeditor5-media-embed/src/automediaembed.ts b/packages/ckeditor5-media-embed/src/automediaembed.ts index b579c251ff7..1a46e88f6cb 100644 --- a/packages/ckeditor5-media-embed/src/automediaembed.ts +++ b/packages/ckeditor5-media-embed/src/automediaembed.ts @@ -9,13 +9,14 @@ import { type Editor, Plugin, type PluginDependencies } from 'ckeditor5/src/core'; import { LiveRange, LivePosition } from 'ckeditor5/src/engine'; -import { Clipboard } from 'ckeditor5/src/clipboard'; +import { Clipboard, type ClipboardPipeline } from 'ckeditor5/src/clipboard'; import { Delete } from 'ckeditor5/src/typing'; -import { Undo } from 'ckeditor5/src/undo'; +import { Undo, type UndoCommand } from 'ckeditor5/src/undo'; import { global } from 'ckeditor5/src/utils'; import MediaEmbedEditing from './mediaembedediting'; import { insertMedia } from './utils'; +import type MediaEmbedCommand from './mediaembedcommand'; const URL_REGEXP = /^(?:http(s)?:\/\/)?[\w-]+\.[\w-.~:/?#[\]@!$&'()*+,;=%]+$/; @@ -70,7 +71,8 @@ export default class AutoMediaEmbed extends Plugin { // We need to listen on `Clipboard#inputTransformation` because we need to save positions of selection. // After pasting, the content between those positions will be checked for a URL that could be transformed // into media. - this.listenTo( editor.plugins.get( 'ClipboardPipeline' ), 'inputTransformation', () => { + const clipboardPipeline: ClipboardPipeline = editor.plugins.get( 'ClipboardPipeline' ); + this.listenTo( clipboardPipeline, 'inputTransformation', () => { const firstRange = modelDocument.selection.getFirstRange()!; const leftLivePosition = LivePosition.fromPosition( firstRange.start ); @@ -87,7 +89,8 @@ export default class AutoMediaEmbed extends Plugin { }, { priority: 'high' } ); } ); - editor.commands.get( 'undo' )!.on( 'execute', () => { + const undoCommand: UndoCommand = editor.commands.get( 'undo' )!; + undoCommand.on( 'execute', () => { if ( this._timeoutId ) { global.window.clearTimeout( this._timeoutId ); this._positionToInsert!.detach(); @@ -136,7 +139,7 @@ export default class AutoMediaEmbed extends Plugin { return; } - const mediaEmbedCommand = editor.commands.get( 'mediaEmbed' )!; + const mediaEmbedCommand: MediaEmbedCommand = editor.commands.get( 'mediaEmbed' )!; // Do not anything if media element cannot be inserted at the current position (#47). if ( !mediaEmbedCommand.isEnabled ) { @@ -170,7 +173,7 @@ export default class AutoMediaEmbed extends Plugin { this._positionToInsert = null; } ); - editor.plugins.get( 'Delete' ).requestUndoOnBackspace(); + editor.plugins.get( Delete ).requestUndoOnBackspace(); }, 100 ); } } diff --git a/packages/ckeditor5-media-embed/src/utils.ts b/packages/ckeditor5-media-embed/src/utils.ts index 73e78934cb5..f8b5fcf58a6 100644 --- a/packages/ckeditor5-media-embed/src/utils.ts +++ b/packages/ckeditor5-media-embed/src/utils.ts @@ -16,8 +16,7 @@ import type { DowncastWriter, ViewDocumentSelection, ViewElement, - DocumentSelection, - Node + DocumentSelection } from 'ckeditor5/src/engine'; import { isWidget, toWidget } from 'ckeditor5/src/widget'; import type MediaRegistry from './mediaregistry'; diff --git a/packages/ckeditor5-undo/src/index.ts b/packages/ckeditor5-undo/src/index.ts index a1a6483e4ce..b27f3961843 100644 --- a/packages/ckeditor5-undo/src/index.ts +++ b/packages/ckeditor5-undo/src/index.ts @@ -10,3 +10,4 @@ export { default as Undo } from './undo'; export { default as UndoEditing } from './undoediting'; export { default as UndoUi } from './undoui'; +export type { default as UndoCommand } from './undocommand'; From 1cf2d6fa28060ae3edca361f222164fcc04b0d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Remiszewski?= Date: Mon, 13 Feb 2023 12:03:17 +0100 Subject: [PATCH 6/8] Move config to one file, fix docs. --- .../ckeditor5-media-embed/src/mediaembed.ts | 283 ---------------- .../src/mediaembedcommand.ts | 7 + .../src/mediaembedconfig.ts | 303 ++++++++++++++++++ .../src/mediaembedediting.ts | 7 +- .../src/mediaembedtoolbar.ts | 13 - .../src/mediaregistry.ts | 3 +- .../src/ui/mediaformview.ts | 17 - 7 files changed, 312 insertions(+), 321 deletions(-) create mode 100644 packages/ckeditor5-media-embed/src/mediaembedconfig.ts diff --git a/packages/ckeditor5-media-embed/src/mediaembed.ts b/packages/ckeditor5-media-embed/src/mediaembed.ts index fc0321bf5ca..d5add41200d 100644 --- a/packages/ckeditor5-media-embed/src/mediaembed.ts +++ b/packages/ckeditor5-media-embed/src/mediaembed.ts @@ -43,291 +43,8 @@ export default class MediaEmbed extends Plugin { return 'MediaEmbed'; } } - -/** - * The media embed provider descriptor. Used in - * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and - * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. - * - * See {@link module:media-embed/mediaembed~MediaEmbedConfig} to learn more. - * - * ```ts - * { - * name: 'example', - * - * // The following RegExp matches https://www.example.com/media/{media id}, - * // (either with "http(s)://" and "www" or without), so the valid URLs are: - * // - * // * https://www.example.com/media/{media id}, - * // * http://www.example.com/media/{media id}, - * // * www.example.com/media/{media id}, - * // * example.com/media/{media id} - * url: /^example\.com\/media\/(\w+)/, - * - * // The rendering function of the provider. - * // Used to represent the media when editing the content (i.e. in the view) - * // and also in the data output of the editor if semantic data output is disabled. - * html: match => `The HTML representing the media with ID=${ match[ 1 ] }.` - * } - * ``` - * - * You can allow any sort of media in the editor using the "allow–all" `RegExp`. - * But mind that, since URLs are processed in the order of configuration, if one of the previous - * `RegExps` matches the URL, it will have a precedence over this one. - * - * ```ts - * { - * name: 'allow-all', - * url: /^.+/ - * } - * ``` - * - * To implement responsive media, you can use the following HTML structure: - * - * ```ts - * { - * ... - * html: match => - * '
' + - * '' + - * '
' - * } - * ``` - */ -export interface MediaEmbedProvider { - - /** - * The name of the provider. Used e.g. when - * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders removing providers}. - */ - name: string; - - /** - * The `RegExp` object (or array of objects) defining the URL of the media. - * If any URL matches the `RegExp`, it becomes the media in the editor model, as defined by the provider. The result - * of matching (output of `String.prototype.match()`) is passed to the `html` rendering function of the media. - * - * **Note:** You do not need to include the protocol (`http://`, `https://`) and `www` subdomain in your `RegExps`, - * they are stripped from the URLs before matching anyway. - */ - url: ArrayOrItem; - - /** - * The rendering function of the media. The function receives the entire matching - * array from the corresponding `url` `RegExp` as an argument, allowing rendering a dedicated - * preview of the media identified by a certain ID or a hash. When not defined, the media embed feature - * will use a generic media representation in the view and output data. - * Note that when - * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} - * is `true`, the rendering function **will always** be used for the media in the editor data output. - */ - html?: ( match: RegExpMatchArray ) => string; -} - -/** - * The configuration of the media embed features. - * - * Read more about {@glink features/media-embed#configuration configuring the media embed feature}. - * - * ```ts - * ClassicEditor - * .create( editorElement, { - * mediaEmbed: ... // Media embed feature options. - * } ) - * .then( ... ) - * .catch( ... ); - * ``` - * - * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. - */ -export interface MediaEmbedConfig { - - /** - * The default media providers supported by the editor. - * - * The names of providers with rendering functions (previews): - * - * * "dailymotion", - * * "spotify", - * * "youtube", - * * "vimeo" - * - * The names of providers without rendering functions: - * - * * "instagram", - * * "twitter", - * * "googleMaps", - * * "flickr", - * * "facebook" - * - * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more about - * different kinds of media and media providers. - * - * **Note**: The default media provider configuration may not support all possible media URLs, - * only the most common are included. - * - * Media without rendering functions are always represented in the data using the "semantic" markup. See - * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} to - * learn more about possible data outputs. - * - * The priority of media providers corresponds to the order of configuration. The first provider - * to match the URL is always used, even if there are other providers that support a particular URL. - * The URL is never matched against the remaining providers. - * - * To discard **all** default media providers, simply override this configuration with your own - * {@link module:media-embed/mediaembed~MediaEmbedProvider definitions}: - * - * ```ts - * ClassicEditor - * .create( editorElement, { - * plugins: [ MediaEmbed, ... ], - * mediaEmbed: { - * providers: [ - * { - * name: 'myProvider', - * url: /^example\.com\/media\/(\w+)/, - * html: match => '...' - * }, - * ... - * ] - * } - * } ) - * .then( ... ) - * .catch( ... ); - * ``` - * - * You can take inspiration from the default configuration of this feature which you can find in: - * https://github.com/ckeditor/ckeditor5-media-embed/blob/master/src/mediaembedediting.js - * - * To **extend** the list of default providers, use - * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. - * - * To **remove** certain providers, use - * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders `config.mediaEmbed.removeProviders`}. - */ - providers?: Array; - - /** - * The additional media providers supported by the editor. This configuration helps extend the default - * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers}. - * - * ```ts - * ClassicEditor - * .create( editorElement, { - * plugins: [ MediaEmbed, ... ], - * mediaEmbed: { - * extraProviders: [ - * { - * name: 'extraProvider', - * url: /^example\.com\/media\/(\w+)/, - * html: match => '...' - * }, - * ... - * ] - * } - * } ) - * .then( ... ) - * .catch( ... ); - * ``` - * - * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more. - */ - extraProviders?: Array; - - /** - * The list of media providers that should not be used despite being available in - * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and - * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`} - * - * ```ts - * mediaEmbed: { - * removeProviders: [ 'youtube', 'twitter' ] - * } - * ``` - */ - removeProviders: Array; - - /** - * Overrides the element name used for "semantic" data. - * - * This is not relevant if {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} - * is set to `true`. - * - * When not set, the feature produces the `` tag: - * - * ```html - *
- * - *
- * ``` - * - * To override the element name with, for instance, the `o-embed` name: - * - * ```ts - * mediaEmbed: { - * elementName: 'o-embed' - * } - * ``` - * - * This will produce semantic data with the `` tag: - * - * ```html - *
- * - *
- * ``` - * - * @default 'oembed' - */ - elementName?: string; - - /** - * Controls the data format produced by the feature. - * - * When `false` (default), the feature produces "semantic" data, i.e. it does not include the preview of - * the media, just the `` tag with the `url` attribute: - * - * ```ts - *
- * - *
- * ``` - * - * When `true`, the media is represented in the output in the same way it looks in the editor, - * i.e. the media preview is saved to the database: - * - * ```ts - *
- *
- * - *
- *
- * ``` - * - * **Note:** Media without preview are always represented in the data using the "semantic" markup - * regardless of the value of the `previewsInData`. Learn more about different kinds of media - * in the {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} - * configuration description. - * - * @defualt false - */ - previewsInData?: boolean; -} - declare module '@ckeditor/ckeditor5-core' { interface PluginsMap { [ MediaEmbed.pluginName ]: MediaEmbed; } - - interface EditorConfig { - - /** - * The configuration of the {@link module:media-embed/mediaembed~MediaEmbed} feature. - * - * Read more in {@link module:media-embed/mediaembed~MediaEmbedConfig}. - */ - mediaEmbed?: MediaEmbedConfig; - } } diff --git a/packages/ckeditor5-media-embed/src/mediaembedcommand.ts b/packages/ckeditor5-media-embed/src/mediaembedcommand.ts index eb93ff64de6..d538bcc2280 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedcommand.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedcommand.ts @@ -89,3 +89,10 @@ function isMediaSelected( selection: Selection | DocumentSelection ): boolean { const element = selection.getSelectedElement(); return !!element && element.name === 'media'; } + +declare module '@ckeditor/ckeditor5-core' { + interface CommandsMap { + + mediaEmbed: MediaEmbedCommand; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaembedconfig.ts b/packages/ckeditor5-media-embed/src/mediaembedconfig.ts new file mode 100644 index 00000000000..182636bc89f --- /dev/null +++ b/packages/ckeditor5-media-embed/src/mediaembedconfig.ts @@ -0,0 +1,303 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import type { ToolbarConfigItem } from 'ckeditor5/src/core'; +import type { ArrayOrItem } from 'ckeditor5/src/utils'; + +/** + * @module media-embed/mediaembedconfig + */ + +/** + * The configuration of the media embed features. + * + * Read more about {@glink features/media-embed#configuration configuring the media embed feature}. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * mediaEmbed: ... // Media embed feature options. + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + */ +export interface MediaEmbedConfig { + + /** + * The default media providers supported by the editor. + * + * The names of providers with rendering functions (previews): + * + * * "dailymotion", + * * "spotify", + * * "youtube", + * * "vimeo" + * + * The names of providers without rendering functions: + * + * * "instagram", + * * "twitter", + * * "googleMaps", + * * "flickr", + * * "facebook" + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more about + * different kinds of media and media providers. + * + * **Note**: The default media provider configuration may not support all possible media URLs, + * only the most common are included. + * + * Media without rendering functions are always represented in the data using the "semantic" markup. See + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} to + * learn more about possible data outputs. + * + * The priority of media providers corresponds to the order of configuration. The first provider + * to match the URL is always used, even if there are other providers that support a particular URL. + * The URL is never matched against the remaining providers. + * + * To discard **all** default media providers, simply override this configuration with your own + * {@link module:media-embed/mediaembed~MediaEmbedProvider definitions}: + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * providers: [ + * { + * name: 'myProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * You can take inspiration from the default configuration of this feature which you can find in: + * https://github.com/ckeditor/ckeditor5-media-embed/blob/master/src/mediaembedediting.js + * + * To **extend** the list of default providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * To **remove** certain providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders `config.mediaEmbed.removeProviders`}. + */ + providers?: Array; + + /** + * The additional media providers supported by the editor. This configuration helps extend the default + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers}. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * extraProviders: [ + * { + * name: 'extraProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more. + */ + extraProviders?: Array; + + /** + * The list of media providers that should not be used despite being available in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`} + * + * ```ts + * mediaEmbed: { + * removeProviders: [ 'youtube', 'twitter' ] + * } + * ``` + */ + removeProviders?: Array; + + /** + * Overrides the element name used for "semantic" data. + * + * This is not relevant if {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is set to `true`. + * + * When not set, the feature produces the `` tag: + * + * ```html + *
+ * + *
+ * ``` + * + * To override the element name with, for instance, the `o-embed` name: + * + * ```ts + * mediaEmbed: { + * elementName: 'o-embed' + * } + * ``` + * + * This will produce semantic data with the `` tag: + * + * ```html + *
+ * + *
+ * ``` + * + * @default 'oembed' + */ + elementName?: string; + + /** + * Controls the data format produced by the feature. + * + * When `false` (default), the feature produces "semantic" data, i.e. it does not include the preview of + * the media, just the `` tag with the `url` attribute: + * + * ```ts + *
+ * + *
+ * ``` + * + * When `true`, the media is represented in the output in the same way it looks in the editor, + * i.e. the media preview is saved to the database: + * + * ```ts + *
+ *
+ * + *
+ *
+ * ``` + * + * **Note:** Media without preview are always represented in the data using the "semantic" markup + * regardless of the value of the `previewsInData`. Learn more about different kinds of media + * in the {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} + * configuration description. + * + * @defualt false + */ + previewsInData?: boolean; + + /** + * Items to be placed in the media embed toolbar. + * This option requires adding {@link module:media-embed/mediaembedtoolbar~MediaEmbedToolbar} to the plugin list. + * + * Read more about configuring toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. + */ + toolbar?: Array; +} + +/** + * The media embed provider descriptor. Used in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * See {@link module:media-embed/mediaembed~MediaEmbedConfig} to learn more. + * + * ```ts + * { + * name: 'example', + * + * // The following RegExp matches https://www.example.com/media/{media id}, + * // (either with "http(s)://" and "www" or without), so the valid URLs are: + * // + * // * https://www.example.com/media/{media id}, + * // * http://www.example.com/media/{media id}, + * // * www.example.com/media/{media id}, + * // * example.com/media/{media id} + * url: /^example\.com\/media\/(\w+)/, + * + * // The rendering function of the provider. + * // Used to represent the media when editing the content (i.e. in the view) + * // and also in the data output of the editor if semantic data output is disabled. + * html: match => `The HTML representing the media with ID=${ match[ 1 ] }.` + * } + * ``` + * + * You can allow any sort of media in the editor using the "allow–all" `RegExp`. + * But mind that, since URLs are processed in the order of configuration, if one of the previous + * `RegExps` matches the URL, it will have a precedence over this one. + * + * ```ts + * { + * name: 'allow-all', + * url: /^.+/ + * } + * ``` + * + * To implement responsive media, you can use the following HTML structure: + * + * ```ts + * { + * ... + * html: match => + * '
' + + * '' + + * '
' + * } + * ``` + */ +export interface MediaEmbedProvider { + + /** + * The name of the provider. Used e.g. when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders removing providers}. + */ + name: string; + + /** + * The `RegExp` object (or array of objects) defining the URL of the media. + * If any URL matches the `RegExp`, it becomes the media in the editor model, as defined by the provider. The result + * of matching (output of `String.prototype.match()`) is passed to the `html` rendering function of the media. + * + * **Note:** You do not need to include the protocol (`http://`, `https://`) and `www` subdomain in your `RegExps`, + * they are stripped from the URLs before matching anyway. + */ + url: ArrayOrItem; + + /** + * The rendering function of the media. The function receives the entire matching + * array from the corresponding `url` `RegExp` as an argument, allowing rendering a dedicated + * preview of the media identified by a certain ID or a hash. When not defined, the media embed feature + * will use a generic media representation in the view and output data. + * Note that when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is `true`, the rendering function **will always** be used for the media in the editor data output. + */ + html?: ( match: RegExpMatchArray ) => string; +} + +declare module '@ckeditor/ckeditor5-core' { + interface EditorConfig { + + /** + * The configuration of the {@link module:media-embed/mediaembed~MediaEmbed} feature. + * + * Read more in {@link module:media-embed/mediaembed~MediaEmbedConfig}. + */ + mediaEmbed?: MediaEmbedConfig; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaembedediting.ts b/packages/ckeditor5-media-embed/src/mediaembedediting.ts index f42c6e2a987..e2aa2a54b5d 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedediting.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedediting.ts @@ -12,7 +12,7 @@ import type { UpcastElementEvent } from 'ckeditor5/src/engine'; import { first, type GetCallback } from 'ckeditor5/src/utils'; import { modelToViewUrlAttributeConverter } from './converters'; -import type { MediaEmbedConfig } from './mediaembed'; +import type { MediaEmbedConfig } from './mediaembedconfig'; import MediaEmbedCommand from './mediaembedcommand'; import MediaRegistry from './mediaregistry'; import { toMediaWidget, createMediaFigureElement } from './utils'; @@ -286,9 +286,4 @@ declare module '@ckeditor/ckeditor5-core' { interface PluginsMap { [ MediaEmbedEditing.pluginName ]: MediaEmbedEditing; } - - interface CommandsMap { - - mediaEmbed: MediaEmbedCommand; - } } diff --git a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts index 7b2e3c84ace..b38d575a53f 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts @@ -53,16 +53,3 @@ declare module '@ckeditor/ckeditor5-core' { [ MediaEmbedToolbar.pluginName ]: MediaEmbedToolbar; } } - -declare module './mediaembed' { - interface MediaEmbedConfig { - - /** - * Items to be placed in the media embed toolbar. - * This option requires adding {@link module:media-embed/mediaembedtoolbar~MediaEmbedToolbar} to the plugin list. - * - * Read more about configuring toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. - */ - toolbar?: Array; - } -} diff --git a/packages/ckeditor5-media-embed/src/mediaregistry.ts b/packages/ckeditor5-media-embed/src/mediaregistry.ts index 3e9e12a0873..fb7a4e2e58a 100644 --- a/packages/ckeditor5-media-embed/src/mediaregistry.ts +++ b/packages/ckeditor5-media-embed/src/mediaregistry.ts @@ -12,7 +12,7 @@ import { IconView, Template } from 'ckeditor5/src/ui'; import { type Locale, logWarning, toArray } from 'ckeditor5/src/utils'; import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg'; -import type { MediaEmbedConfig, MediaEmbedProvider } from './mediaembed'; +import type { MediaEmbedConfig, MediaEmbedProvider } from './mediaembedconfig'; import type { MediaOptions } from './utils'; const mediaPlaceholderIconViewBox = '0 0 64 42'; @@ -175,7 +175,6 @@ class Media { * Shorthand for {@link module:utils/locale~Locale#t}. * * @see module:utils/locale~Locale#t - * @method */ private _locale: Locale; diff --git a/packages/ckeditor5-media-embed/src/ui/mediaformview.ts b/packages/ckeditor5-media-embed/src/ui/mediaformview.ts index bf5137a5bfc..6dd41894a0e 100644 --- a/packages/ckeditor5-media-embed/src/ui/mediaformview.ts +++ b/packages/ckeditor5-media-embed/src/ui/mediaformview.ts @@ -33,15 +33,11 @@ import '../../theme/mediaform.css'; export default class MediaFormView extends View { /** * Tracks information about the DOM focus in the form. - * - * @readonly */ public readonly focusTracker: FocusTracker; /** * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. - * - * @readonly */ public readonly keystrokes: KeystrokeHandler; @@ -314,16 +310,3 @@ export default class MediaFormView extends View { return button; } } - -/** - * Fired when the form view is submitted (when one of the children triggered the submit event), - * e.g. click on {@link #saveButtonView}. - * - * @eventName submit - */ - -/** - * Fired when the form view is canceled, e.g. by a click on {@link #cancelButtonView}. - * - * @eventName cancel - */ From 1beef433028f389b3699c6988fd9312a53c503f1 Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Wed, 15 Feb 2023 11:41:37 +0100 Subject: [PATCH 7/8] Misc. fixes in ckeditor5-media-embed. --- packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts | 4 +++- packages/ckeditor5-media-embed/src/mediaembedui.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts index b38d575a53f..1256c8c09f9 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts @@ -7,11 +7,13 @@ * @module media-embed/mediaembedtoolbar */ -import { Plugin, type ToolbarConfigItem, type PluginDependencies } from 'ckeditor5/src/core'; +import { Plugin, type PluginDependencies } from 'ckeditor5/src/core'; import { WidgetToolbarRepository } from 'ckeditor5/src/widget'; import { getSelectedMediaViewWidget } from './utils'; +import './mediaembedconfig'; + /** * The media embed toolbar plugin. It creates a toolbar for media embed that shows up when the media element is selected. * diff --git a/packages/ckeditor5-media-embed/src/mediaembedui.ts b/packages/ckeditor5-media-embed/src/mediaembedui.ts index 0e1380a2b2f..89b6c257d11 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedui.ts +++ b/packages/ckeditor5-media-embed/src/mediaembedui.ts @@ -40,7 +40,7 @@ export default class MediaEmbedUI extends Plugin { */ public init(): void { const editor = this.editor; - const command = editor.commands.get( 'mediaEmbed' )!; + const command: MediaEmbedCommand = editor.commands.get( 'mediaEmbed' )!; editor.ui.componentFactory.add( 'mediaEmbed', locale => { const dropdown = createDropdown( locale ); From 305e035a95680a537e28044b275a165ff4d3f54e Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Wed, 15 Feb 2023 12:10:06 +0100 Subject: [PATCH 8/8] Added ckeditor5-media-embed/_src. --- .../_src/automediaembed.js | 182 +++++++++ .../ckeditor5-media-embed/_src/converters.js | 60 +++ packages/ckeditor5-media-embed/_src/index.js | 14 + .../ckeditor5-media-embed/_src/mediaembed.js | 292 +++++++++++++++ .../_src/mediaembedcommand.js | 87 +++++ .../_src/mediaembedediting.js | 280 ++++++++++++++ .../_src/mediaembedtoolbar.js | 61 +++ .../_src/mediaembedui.js | 127 +++++++ .../_src/mediaregistry.js | 335 +++++++++++++++++ .../_src/ui/mediaformview.js | 352 ++++++++++++++++++ packages/ckeditor5-media-embed/_src/utils.js | 122 ++++++ 11 files changed, 1912 insertions(+) create mode 100644 packages/ckeditor5-media-embed/_src/automediaembed.js create mode 100644 packages/ckeditor5-media-embed/_src/converters.js create mode 100644 packages/ckeditor5-media-embed/_src/index.js create mode 100644 packages/ckeditor5-media-embed/_src/mediaembed.js create mode 100644 packages/ckeditor5-media-embed/_src/mediaembedcommand.js create mode 100644 packages/ckeditor5-media-embed/_src/mediaembedediting.js create mode 100644 packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js create mode 100644 packages/ckeditor5-media-embed/_src/mediaembedui.js create mode 100644 packages/ckeditor5-media-embed/_src/mediaregistry.js create mode 100644 packages/ckeditor5-media-embed/_src/ui/mediaformview.js create mode 100644 packages/ckeditor5-media-embed/_src/utils.js diff --git a/packages/ckeditor5-media-embed/_src/automediaembed.js b/packages/ckeditor5-media-embed/_src/automediaembed.js new file mode 100644 index 00000000000..df73979a475 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/automediaembed.js @@ -0,0 +1,182 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/automediaembed + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { LiveRange, LivePosition } from 'ckeditor5/src/engine'; +import { Clipboard } from 'ckeditor5/src/clipboard'; +import { Delete } from 'ckeditor5/src/typing'; +import { Undo } from 'ckeditor5/src/undo'; +import { global } from 'ckeditor5/src/utils'; + +import MediaEmbedEditing from './mediaembedediting'; +import { insertMedia } from './utils'; + +const URL_REGEXP = /^(?:http(s)?:\/\/)?[\w-]+\.[\w-.~:/?#[\]@!$&'()*+,;=%]+$/; + +/** + * The auto-media embed plugin. It recognizes media links in the pasted content and embeds + * them shortly after they are injected into the document. + * + * @extends module:core/plugin~Plugin + */ +export default class AutoMediaEmbed extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ Clipboard, Delete, Undo ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'AutoMediaEmbed'; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + /** + * The paste–to–embed `setTimeout` ID. Stored as a property to allow + * cleaning of the timeout. + * + * @private + * @member {Number} #_timeoutId + */ + this._timeoutId = null; + + /** + * The position where the `` element will be inserted after the timeout, + * determined each time the new content is pasted into the document. + * + * @private + * @member {module:engine/model/liveposition~LivePosition} #_positionToInsert + */ + this._positionToInsert = null; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const modelDocument = editor.model.document; + + // We need to listen on `Clipboard#inputTransformation` because we need to save positions of selection. + // After pasting, the content between those positions will be checked for a URL that could be transformed + // into media. + this.listenTo( editor.plugins.get( 'ClipboardPipeline' ), 'inputTransformation', () => { + const firstRange = modelDocument.selection.getFirstRange(); + + const leftLivePosition = LivePosition.fromPosition( firstRange.start ); + leftLivePosition.stickiness = 'toPrevious'; + + const rightLivePosition = LivePosition.fromPosition( firstRange.end ); + rightLivePosition.stickiness = 'toNext'; + + modelDocument.once( 'change:data', () => { + this._embedMediaBetweenPositions( leftLivePosition, rightLivePosition ); + + leftLivePosition.detach(); + rightLivePosition.detach(); + }, { priority: 'high' } ); + } ); + + editor.commands.get( 'undo' ).on( 'execute', () => { + if ( this._timeoutId ) { + global.window.clearTimeout( this._timeoutId ); + this._positionToInsert.detach(); + + this._timeoutId = null; + this._positionToInsert = null; + } + }, { priority: 'high' } ); + } + + /** + * Analyzes the part of the document between provided positions in search for a URL representing media. + * When the URL is found, it is automatically converted into media. + * + * @protected + * @param {module:engine/model/liveposition~LivePosition} leftPosition Left position of the selection. + * @param {module:engine/model/liveposition~LivePosition} rightPosition Right position of the selection. + */ + _embedMediaBetweenPositions( leftPosition, rightPosition ) { + const editor = this.editor; + const mediaRegistry = editor.plugins.get( MediaEmbedEditing ).registry; + // TODO: Use marker instead of LiveRange & LivePositions. + const urlRange = new LiveRange( leftPosition, rightPosition ); + const walker = urlRange.getWalker( { ignoreElementEnd: true } ); + + let url = ''; + + for ( const node of walker ) { + if ( node.item.is( '$textProxy' ) ) { + url += node.item.data; + } + } + + url = url.trim(); + + // If the URL does not match to universal URL regexp, let's skip that. + if ( !url.match( URL_REGEXP ) ) { + urlRange.detach(); + + return; + } + + // If the URL represents a media, let's use it. + if ( !mediaRegistry.hasMedia( url ) ) { + urlRange.detach(); + + return; + } + + const mediaEmbedCommand = editor.commands.get( 'mediaEmbed' ); + + // Do not anything if media element cannot be inserted at the current position (#47). + if ( !mediaEmbedCommand.isEnabled ) { + urlRange.detach(); + + return; + } + + // Position won't be available in the `setTimeout` function so let's clone it. + this._positionToInsert = LivePosition.fromPosition( leftPosition ); + + // This action mustn't be executed if undo was called between pasting and auto-embedding. + this._timeoutId = global.window.setTimeout( () => { + editor.model.change( writer => { + this._timeoutId = null; + + writer.remove( urlRange ); + urlRange.detach(); + + let insertionPosition; + + // Check if position where the media element should be inserted is still valid. + // Otherwise leave it as undefined to use document.selection - default behavior of model.insertContent(). + if ( this._positionToInsert.root.rootName !== '$graveyard' ) { + insertionPosition = this._positionToInsert; + } + + insertMedia( editor.model, url, insertionPosition, false ); + + this._positionToInsert.detach(); + this._positionToInsert = null; + } ); + + editor.plugins.get( 'Delete' ).requestUndoOnBackspace(); + }, 100 ); + } +} diff --git a/packages/ckeditor5-media-embed/_src/converters.js b/packages/ckeditor5-media-embed/_src/converters.js new file mode 100644 index 00000000000..99cd5fcb1e3 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/converters.js @@ -0,0 +1,60 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/converters + */ + +/** + * Returns a function that converts the model "url" attribute to the view representation. + * + * Depending on the configuration, the view representation can be "semantic" (for the data pipeline): + * + *
+ * + *
+ * + * or "non-semantic" (for the editing view pipeline): + * + *
+ *
[ non-semantic media preview for "foo" ]
+ *
+ * + * **Note:** Changing the model "url" attribute replaces the entire content of the + * `
` in the view. + * + * @param {module:media-embed/mediaregistry~MediaRegistry} registry The registry providing + * the media and their content. + * @param {Object} options + * @param {String} [options.elementName] When set, overrides the default element name for semantic media embeds. + * @param {String} [options.renderMediaPreview] When `true`, the converter will create the view in the non-semantic form. + * @param {String} [options.renderForEditingView] When `true`, the converter will create a view specific for the + * editing pipeline (e.g. including CSS classes, content placeholders). + * @returns {Function} + */ +export function modelToViewUrlAttributeConverter( registry, options ) { + return dispatcher => { + dispatcher.on( 'attribute:url:media', converter ); + }; + + function converter( evt, data, conversionApi ) { + if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { + return; + } + + const url = data.attributeNewValue; + const viewWriter = conversionApi.writer; + const figure = conversionApi.mapper.toViewElement( data.item ); + const mediaContentElement = [ ...figure.getChildren() ] + .find( child => child.getCustomProperty( 'media-content' ) ); + + // TODO: removing the wrapper and creating it from scratch is a hack. We can do better than that. + viewWriter.remove( mediaContentElement ); + + const mediaViewElement = registry.getMediaViewElement( viewWriter, url, options ); + + viewWriter.insert( viewWriter.createPositionAt( figure, 0 ), mediaViewElement ); + } +} diff --git a/packages/ckeditor5-media-embed/_src/index.js b/packages/ckeditor5-media-embed/_src/index.js new file mode 100644 index 00000000000..ecd4a3f22eb --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/index.js @@ -0,0 +1,14 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed + */ + +export { default as MediaEmbed } from './mediaembed'; +export { default as MediaEmbedEditing } from './mediaembedediting'; +export { default as MediaEmbedUI } from './mediaembedui'; +export { default as AutoMediaEmbed } from './automediaembed'; +export { default as MediaEmbedToolbar } from './mediaembedtoolbar'; diff --git a/packages/ckeditor5-media-embed/_src/mediaembed.js b/packages/ckeditor5-media-embed/_src/mediaembed.js new file mode 100644 index 00000000000..6648ae53d35 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembed.js @@ -0,0 +1,292 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembed + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { Widget } from 'ckeditor5/src/widget'; + +import MediaEmbedEditing from './mediaembedediting'; +import AutoMediaEmbed from './automediaembed'; +import MediaEmbedUI from './mediaembedui'; + +import '../theme/mediaembed.css'; + +/** + * The media embed plugin. + * + * For a detailed overview, check the {@glink features/media-embed Media Embed feature documentation}. + * + * This is a "glue" plugin which loads the following plugins: + * + * * The {@link module:media-embed/mediaembedediting~MediaEmbedEditing media embed editing feature}, + * * The {@link module:media-embed/mediaembedui~MediaEmbedUI media embed UI feature} and + * * The {@link module:media-embed/automediaembed~AutoMediaEmbed auto-media embed feature}. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbed extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ MediaEmbedEditing, MediaEmbedUI, AutoMediaEmbed, Widget ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbed'; + } +} + +/** + * The media embed provider descriptor. Used in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * See {@link module:media-embed/mediaembed~MediaEmbedConfig} to learn more. + * + * { + * name: 'example', + * + * // The following RegExp matches https://www.example.com/media/{media id}, + * // (either with "http(s)://" and "www" or without), so the valid URLs are: + * // + * // * https://www.example.com/media/{media id}, + * // * http://www.example.com/media/{media id}, + * // * www.example.com/media/{media id}, + * // * example.com/media/{media id} + * url: /^example\.com\/media\/(\w+)/, + * + * // The rendering function of the provider. + * // Used to represent the media when editing the content (i.e. in the view) + * // and also in the data output of the editor if semantic data output is disabled. + * html: match => `The HTML representing the media with ID=${ match[ 1 ] }.` + * } + * + * You can allow any sort of media in the editor using the "allow–all" `RegExp`. + * But mind that, since URLs are processed in the order of configuration, if one of the previous + * `RegExps` matches the URL, it will have a precedence over this one. + * + * { + * name: 'allow-all', + * url: /^.+/ + * } + * + * To implement responsive media, you can use the following HTML structure: + * + * { + * ... + * html: match => + * '
' + + * '' + + * '
' + * } + * + * @typedef {Object} module:media-embed/mediaembed~MediaEmbedProvider + * @property {String} name The name of the provider. Used e.g. when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders removing providers}. + * @property {RegExp|Array.} url The `RegExp` object (or array of objects) defining the URL of the media. + * If any URL matches the `RegExp`, it becomes the media in the editor model, as defined by the provider. The result + * of matching (output of `String.prototype.match()`) is passed to the `html` rendering function of the media. + * + * **Note:** You do not need to include the protocol (`http://`, `https://`) and `www` subdomain in your `RegExps`, + * they are stripped from the URLs before matching anyway. + * @property {Function} [html] (optional) The rendering function of the media. The function receives the entire matching + * array from the corresponding `url` `RegExp` as an argument, allowing rendering a dedicated + * preview of the media identified by a certain ID or a hash. When not defined, the media embed feature + * will use a generic media representation in the view and output data. + * Note that when + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is `true`, the rendering function **will always** be used for the media in the editor data output. + */ + +/** + * The configuration of the {@link module:media-embed/mediaembed~MediaEmbed} feature. + * + * Read more in {@link module:media-embed/mediaembed~MediaEmbedConfig}. + * + * @member {module:media-embed/mediaembed~MediaEmbedConfig} module:core/editor/editorconfig~EditorConfig#mediaEmbed + */ + +/** + * The configuration of the media embed features. + * + * Read more about {@glink features/media-embed#configuration configuring the media embed feature}. + * + * ClassicEditor + * .create( editorElement, { + * mediaEmbed: ... // Media embed feature options. + * } ) + * .then( ... ) + * .catch( ... ); + * + * See {@link module:core/editor/editorconfig~EditorConfig all editor options}. + * + * @interface MediaEmbedConfig + */ + +/** + * The default media providers supported by the editor. + * + * The names of providers with rendering functions (previews): + * + * * "dailymotion", + * * "spotify", + * * "youtube", + * * "vimeo" + * + * The names of providers without rendering functions: + * + * * "instagram", + * * "twitter", + * * "googleMaps", + * * "flickr", + * * "facebook" + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more about + * different kinds of media and media providers. + * + * **Note**: The default media provider configuration may not support all possible media URLs, + * only the most common are included. + * + * Media without rendering functions are always represented in the data using the "semantic" markup. See + * {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} to + * learn more about possible data outputs. + * + * The priority of media providers corresponds to the order of configuration. The first provider + * to match the URL is always used, even if there are other providers that support a particular URL. + * The URL is never matched against the remaining providers. + * + * To discard **all** default media providers, simply override this configuration with your own + * {@link module:media-embed/mediaembed~MediaEmbedProvider definitions}: + * + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * providers: [ + * { + * name: 'myProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * + * You can take inspiration from the default configuration of this feature which you can find in: + * https://github.com/ckeditor/ckeditor5-media-embed/blob/master/src/mediaembedediting.js + * + * To **extend** the list of default providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`}. + * + * To **remove** certain providers, use + * {@link module:media-embed/mediaembed~MediaEmbedConfig#removeProviders `config.mediaEmbed.removeProviders`}. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#providers + */ + +/** + * The additional media providers supported by the editor. This configuration helps extend the default + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers}. + * + * ClassicEditor + * .create( editorElement, { + * plugins: [ MediaEmbed, ... ], + * mediaEmbed: { + * extraProviders: [ + * { + * name: 'extraProvider', + * url: /^example\.com\/media\/(\w+)/, + * html: match => '...' + * }, + * ... + * ] + * } + * } ) + * .then( ... ) + * .catch( ... ); + * + * See the {@link module:media-embed/mediaembed~MediaEmbedProvider provider syntax} to learn more. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#extraProviders + */ + +/** + * The list of media providers that should not be used despite being available in + * {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} and + * {@link module:media-embed/mediaembed~MediaEmbedConfig#extraProviders `config.mediaEmbed.extraProviders`} + * + * mediaEmbed: { + * removeProviders: [ 'youtube', 'twitter' ] + * } + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#removeProviders + */ + +/** + * Overrides the element name used for "semantic" data. + * + * This is not relevant if {@link module:media-embed/mediaembed~MediaEmbedConfig#previewsInData `config.mediaEmbed.previewsInData`} + * is set to `true`. + * + * When not set, the feature produces the `` tag: + * + *
+ * + *
+ * + * To override the element name with, for instance, the `o-embed` name: + * + * mediaEmbed: { + * elementName: 'o-embed' + * } + * + * This will produce semantic data with the `` tag: + * + *
+ * + *
+ * + * @default 'oembed' + * @member {String} [module:media-embed/mediaembed~MediaEmbedConfig#elementName] + */ + +/** + * Controls the data format produced by the feature. + * + * When `false` (default), the feature produces "semantic" data, i.e. it does not include the preview of + * the media, just the `` tag with the `url` attribute: + * + *
+ * + *
+ * + * When `true`, the media is represented in the output in the same way it looks in the editor, + * i.e. the media preview is saved to the database: + * + *
+ *
+ * + *
+ *
+ * + * **Note:** Media without preview are always represented in the data using the "semantic" markup + * regardless of the value of the `previewsInData`. Learn more about different kinds of media + * in the {@link module:media-embed/mediaembed~MediaEmbedConfig#providers `config.mediaEmbed.providers`} + * configuration description. + * + * @member {Boolean} [module:media-embed/mediaembed~MediaEmbedConfig#previewsInData=false] + */ diff --git a/packages/ckeditor5-media-embed/_src/mediaembedcommand.js b/packages/ckeditor5-media-embed/_src/mediaembedcommand.js new file mode 100644 index 00000000000..c17ddf81680 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembedcommand.js @@ -0,0 +1,87 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { findOptimalInsertionRange } from 'ckeditor5/src/widget'; +import { getSelectedMediaModelWidget, insertMedia } from './utils'; + +/** + * The insert media command. + * + * The command is registered by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} as `'mediaEmbed'`. + * + * To insert media at the current selection, execute the command and specify the URL: + * + * editor.execute( 'mediaEmbed', 'http://url.to.the/media' ); + * + * @extends module:core/command~Command + */ +export default class MediaEmbedCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const model = this.editor.model; + const selection = model.document.selection; + const selectedMedia = getSelectedMediaModelWidget( selection ); + + this.value = selectedMedia ? selectedMedia.getAttribute( 'url' ) : null; + + this.isEnabled = isMediaSelected( selection ) || isAllowedInParent( selection, model ); + } + + /** + * Executes the command, which either: + * + * * updates the URL of the selected media, + * * inserts the new media into the editor and puts the selection around it. + * + * @fires execute + * @param {String} url The URL of the media. + */ + execute( url ) { + const model = this.editor.model; + const selection = model.document.selection; + const selectedMedia = getSelectedMediaModelWidget( selection ); + + if ( selectedMedia ) { + model.change( writer => { + writer.setAttribute( 'url', url, selectedMedia ); + } ); + } else { + insertMedia( model, url, selection, true ); + } + } +} + +// Checks if the table is allowed in the parent. +// +// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection +// @param {module:engine/model/model~Model} model +// @returns {Boolean} +function isAllowedInParent( selection, model ) { + const insertionRange = findOptimalInsertionRange( selection, model ); + let parent = insertionRange.start.parent; + + // The model.insertContent() will remove empty parent (unless it is a $root or a limit). + if ( parent.isEmpty && !model.schema.isLimit( parent ) ) { + parent = parent.parent; + } + + return model.schema.checkChild( parent, 'media' ); +} + +// Checks if the media object is selected. +// +// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection +// @returns {Boolean} +function isMediaSelected( selection ) { + const element = selection.getSelectedElement(); + return !!element && element.name === 'media'; +} diff --git a/packages/ckeditor5-media-embed/_src/mediaembedediting.js b/packages/ckeditor5-media-embed/_src/mediaembedediting.js new file mode 100644 index 00000000000..17d52e3139c --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembedediting.js @@ -0,0 +1,280 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedediting + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { first } from 'ckeditor5/src/utils'; + +import { modelToViewUrlAttributeConverter } from './converters'; +import MediaEmbedCommand from './mediaembedcommand'; +import MediaRegistry from './mediaregistry'; +import { toMediaWidget, createMediaFigureElement } from './utils'; + +import '../theme/mediaembedediting.css'; + +/** + * The media embed editing feature. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedEditing extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedEditing'; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + editor.config.define( 'mediaEmbed', { + elementName: 'oembed', + providers: [ + { + name: 'dailymotion', + url: /^dailymotion\.com\/video\/(\w+)/, + html: match => { + const id = match[ 1 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'spotify', + url: [ + /^open\.spotify\.com\/(artist\/\w+)/, + /^open\.spotify\.com\/(album\/\w+)/, + /^open\.spotify\.com\/(track\/\w+)/ + ], + html: match => { + const id = match[ 1 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'youtube', + url: [ + /^(?:m\.)?youtube\.com\/watch\?v=([\w-]+)(?:&t=(\d+))?/, + /^(?:m\.)?youtube\.com\/v\/([\w-]+)(?:\?t=(\d+))?/, + /^youtube\.com\/embed\/([\w-]+)(?:\?start=(\d+))?/, + /^youtu\.be\/([\w-]+)(?:\?t=(\d+))?/ + ], + html: match => { + const id = match[ 1 ]; + const time = match[ 2 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'vimeo', + url: [ + /^vimeo\.com\/(\d+)/, + /^vimeo\.com\/[^/]+\/[^/]+\/video\/(\d+)/, + /^vimeo\.com\/album\/[^/]+\/video\/(\d+)/, + /^vimeo\.com\/channels\/[^/]+\/(\d+)/, + /^vimeo\.com\/groups\/[^/]+\/videos\/(\d+)/, + /^vimeo\.com\/ondemand\/[^/]+\/(\d+)/, + /^player\.vimeo\.com\/video\/(\d+)/ + ], + html: match => { + const id = match[ 1 ]; + + return ( + '
' + + `' + + '
' + ); + } + }, + + { + name: 'instagram', + url: /^instagram\.com\/p\/(\w+)/ + }, + { + name: 'twitter', + url: /^twitter\.com/ + }, + { + name: 'googleMaps', + url: [ + /^google\.com\/maps/, + /^goo\.gl\/maps/, + /^maps\.google\.com/, + /^maps\.app\.goo\.gl/ + ] + }, + { + name: 'flickr', + url: /^flickr\.com/ + }, + { + name: 'facebook', + url: /^facebook\.com/ + } + ] + } ); + + /** + * The media registry managing the media providers in the editor. + * + * @member {module:media-embed/mediaregistry~MediaRegistry} #registry + */ + this.registry = new MediaRegistry( editor.locale, editor.config.get( 'mediaEmbed' ) ); + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const schema = editor.model.schema; + const t = editor.t; + const conversion = editor.conversion; + const renderMediaPreview = editor.config.get( 'mediaEmbed.previewsInData' ); + const elementName = editor.config.get( 'mediaEmbed.elementName' ); + + const registry = this.registry; + + editor.commands.add( 'mediaEmbed', new MediaEmbedCommand( editor ) ); + + // Configure the schema. + schema.register( 'media', { + inheritAllFrom: '$blockObject', + allowAttributes: [ 'url' ] + } ); + + // Model -> Data + conversion.for( 'dataDowncast' ).elementToStructure( { + model: 'media', + view: ( modelElement, { writer } ) => { + const url = modelElement.getAttribute( 'url' ); + + return createMediaFigureElement( writer, registry, url, { + elementName, + renderMediaPreview: url && renderMediaPreview + } ); + } + } ); + + // Model -> Data (url -> data-oembed-url) + conversion.for( 'dataDowncast' ).add( + modelToViewUrlAttributeConverter( registry, { + elementName, + renderMediaPreview + } ) ); + + // Model -> View (element) + conversion.for( 'editingDowncast' ).elementToStructure( { + model: 'media', + view: ( modelElement, { writer } ) => { + const url = modelElement.getAttribute( 'url' ); + const figure = createMediaFigureElement( writer, registry, url, { + elementName, + renderForEditingView: true + } ); + + return toMediaWidget( figure, writer, t( 'media widget' ) ); + } + } ); + + // Model -> View (url -> data-oembed-url) + conversion.for( 'editingDowncast' ).add( + modelToViewUrlAttributeConverter( registry, { + elementName, + renderForEditingView: true + } ) ); + + // View -> Model (data-oembed-url -> url) + conversion.for( 'upcast' ) + // Upcast semantic media. + .elementToElement( { + view: element => [ 'oembed', elementName ].includes( element.name ) && element.getAttribute( 'url' ) ? + { name: true } : + null, + model: ( viewMedia, { writer } ) => { + const url = viewMedia.getAttribute( 'url' ); + + if ( registry.hasMedia( url ) ) { + return writer.createElement( 'media', { url } ); + } + } + } ) + // Upcast non-semantic media. + .elementToElement( { + view: { + name: 'div', + attributes: { + 'data-oembed-url': true + } + }, + model: ( viewMedia, { writer } ) => { + const url = viewMedia.getAttribute( 'data-oembed-url' ); + + if ( registry.hasMedia( url ) ) { + return writer.createElement( 'media', { url } ); + } + } + } ) + // Consume `
` elements, that were left after upcast. + .add( dispatcher => { + dispatcher.on( 'element:figure', converter ); + + function converter( evt, data, conversionApi ) { + if ( !conversionApi.consumable.consume( data.viewItem, { name: true, classes: 'media' } ) ) { + return; + } + + const { modelRange, modelCursor } = conversionApi.convertChildren( data.viewItem, data.modelCursor ); + + data.modelRange = modelRange; + data.modelCursor = modelCursor; + + const modelElement = first( modelRange.getItems() ); + + if ( !modelElement ) { + // Revert consumed figure so other features can convert it. + conversionApi.consumable.revert( data.viewItem, { name: true, classes: 'media' } ); + } + } + } ); + } +} diff --git a/packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js b/packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js new file mode 100644 index 00000000000..0a9db20d75e --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js @@ -0,0 +1,61 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedtoolbar + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { WidgetToolbarRepository } from 'ckeditor5/src/widget'; + +import { getSelectedMediaViewWidget } from './utils'; + +/** + * The media embed toolbar plugin. It creates a toolbar for media embed that shows up when the media element is selected. + * + * Instances of toolbar components (e.g. buttons) are created based on the + * {@link module:media-embed/mediaembed~MediaEmbedConfig#toolbar `media.toolbar` configuration option}. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedToolbar extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ WidgetToolbarRepository ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedToolbar'; + } + + /** + * @inheritDoc + */ + afterInit() { + const editor = this.editor; + const t = editor.t; + const widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository ); + + widgetToolbarRepository.register( 'mediaEmbed', { + ariaLabel: t( 'Media toolbar' ), + items: editor.config.get( 'mediaEmbed.toolbar' ) || [], + getRelatedElement: getSelectedMediaViewWidget + } ); + } +} + +/** + * Items to be placed in the media embed toolbar. + * This option requires adding {@link module:media-embed/mediaembedtoolbar~MediaEmbedToolbar} to the plugin list. + * + * Read more about configuring toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}. + * + * @member {Array.} module:media-embed/mediaembed~MediaEmbedConfig#toolbar + */ diff --git a/packages/ckeditor5-media-embed/_src/mediaembedui.js b/packages/ckeditor5-media-embed/_src/mediaembedui.js new file mode 100644 index 00000000000..2db166030ae --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaembedui.js @@ -0,0 +1,127 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaembedui + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { createDropdown } from 'ckeditor5/src/ui'; + +import MediaFormView from './ui/mediaformview'; +import MediaEmbedEditing from './mediaembedediting'; +import mediaIcon from '../theme/icons/media.svg'; + +/** + * The media embed UI plugin. + * + * @extends module:core/plugin~Plugin + */ +export default class MediaEmbedUI extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ MediaEmbedEditing ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'MediaEmbedUI'; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const command = editor.commands.get( 'mediaEmbed' ); + + editor.ui.componentFactory.add( 'mediaEmbed', locale => { + const dropdown = createDropdown( locale ); + + this._setUpDropdown( dropdown, command ); + + return dropdown; + } ); + } + + /** + * @private + * @param {module:ui/dropdown/dropdownview~DropdownView} dropdown + * @param {module:media-embed/mediaembedcommand~MediaEmbedCommand} command + */ + _setUpDropdown( dropdown, command ) { + const editor = this.editor; + const t = editor.t; + const button = dropdown.buttonView; + const registry = editor.plugins.get( MediaEmbedEditing ).registry; + + dropdown.once( 'change:isOpen', () => { + const form = new MediaFormView( getFormValidators( editor.t, registry ), editor.locale ); + + dropdown.panelView.children.add( form ); + + // Note: Use the low priority to make sure the following listener starts working after the + // default action of the drop-down is executed (i.e. the panel showed up). Otherwise, the + // invisible form/input cannot be focused/selected. + button.on( 'open', () => { + form.disableCssTransitions(); + + // 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#fieldView#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.fieldView.select(); + form.enableCssTransitions(); + }, { priority: 'low' } ); + + dropdown.on( 'submit', () => { + if ( form.isValid() ) { + editor.execute( 'mediaEmbed', form.url ); + editor.editing.view.focus(); + } + } ); + + dropdown.on( 'change:isOpen', () => form.resetFormStatus() ); + dropdown.on( 'cancel', () => { + editor.editing.view.focus(); + } ); + + 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 ); + } ); + + dropdown.bind( 'isEnabled' ).to( command ); + + button.set( { + label: t( 'Insert media' ), + icon: mediaIcon, + tooltip: true + } ); + } +} + +function getFormValidators( t, registry ) { + return [ + form => { + if ( !form.url.length ) { + return t( 'The URL must not be empty.' ); + } + }, + form => { + if ( !registry.hasMedia( form.url ) ) { + return t( 'This media URL is not supported.' ); + } + } + ]; +} diff --git a/packages/ckeditor5-media-embed/_src/mediaregistry.js b/packages/ckeditor5-media-embed/_src/mediaregistry.js new file mode 100644 index 00000000000..44818e45ca4 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/mediaregistry.js @@ -0,0 +1,335 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/mediaregistry + */ + +import { IconView, Template } from 'ckeditor5/src/ui'; +import { logWarning, toArray } from 'ckeditor5/src/utils'; + +import mediaPlaceholderIcon from '../theme/icons/media-placeholder.svg'; + +const mediaPlaceholderIconViewBox = '0 0 64 42'; + +/** + * A bridge between the raw media content provider definitions and the editor view content. + * + * It helps translating media URLs to corresponding {@link module:engine/view/element~Element view elements}. + * + * Mostly used by the {@link module:media-embed/mediaembedediting~MediaEmbedEditing} plugin. + */ +export default class MediaRegistry { + /** + * Creates an instance of the {@link module:media-embed/mediaregistry~MediaRegistry} class. + * + * @param {module:utils/locale~Locale} locale The localization services instance. + * @param {module:media-embed/mediaembed~MediaEmbedConfig} config The configuration of the media embed feature. + */ + constructor( locale, config ) { + const providers = config.providers; + const extraProviders = config.extraProviders || []; + const removedProviders = new Set( config.removeProviders ); + const providerDefinitions = providers + .concat( extraProviders ) + .filter( provider => { + const name = provider.name; + + if ( !name ) { + /** + * One of the providers (or extra providers) specified in the media embed configuration + * has no name and will not be used by the editor. In order to get this media + * provider working, double check your editor configuration. + * + * @error media-embed-no-provider-name + */ + logWarning( 'media-embed-no-provider-name', { provider } ); + + return false; + } + + return !removedProviders.has( name ); + } ); + + /** + * The {@link module:utils/locale~Locale} instance. + * + * @member {module:utils/locale~Locale} + */ + this.locale = locale; + + /** + * The media provider definitions available for the registry. Usually corresponding with the + * {@link module:media-embed/mediaembed~MediaEmbedConfig media configuration}. + * + * @member {Array} + */ + this.providerDefinitions = providerDefinitions; + } + + /** + * Checks whether the passed URL is representing a certain media type allowed in the editor. + * + * @param {String} url The URL to be checked + * @returns {Boolean} + */ + hasMedia( url ) { + return !!this._getMedia( url ); + } + + /** + * For the given media URL string and options, it returns the {@link module:engine/view/element~Element view element} + * representing that media. + * + * **Note:** If no URL is specified, an empty view element is returned. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {String} url The URL to be translated into a view element. + * @param {Object} options + * @param {String} [options.elementName] + * @param {Boolean} [options.renderMediaPreview] + * @param {Boolean} [options.renderForEditingView] + * @returns {module:engine/view/element~Element} + */ + getMediaViewElement( writer, url, options ) { + return this._getMedia( url ).getViewElement( writer, options ); + } + + /** + * Returns a `Media` instance for the given URL. + * + * @protected + * @param {String} url The URL of the media. + * @returns {module:media-embed/mediaregistry~Media|null} The `Media` instance or `null` when there is none. + */ + _getMedia( url ) { + if ( !url ) { + return new Media( this.locale ); + } + + url = url.trim(); + + for ( const definition of this.providerDefinitions ) { + const previewRenderer = definition.html; + const pattern = toArray( definition.url ); + + for ( const subPattern of pattern ) { + const match = this._getUrlMatches( url, subPattern ); + + if ( match ) { + return new Media( this.locale, url, match, previewRenderer ); + } + } + } + + return null; + } + + /** + * Tries to match `url` to `pattern`. + * + * @private + * @param {String} url The URL of the media. + * @param {RegExp} pattern The pattern that should accept the media URL. + * @returns {Array|null} + */ + _getUrlMatches( url, pattern ) { + // 1. Try to match without stripping the protocol and "www" subdomain. + let match = url.match( pattern ); + + if ( match ) { + return match; + } + + // 2. Try to match after stripping the protocol. + let rawUrl = url.replace( /^https?:\/\//, '' ); + match = rawUrl.match( pattern ); + + if ( match ) { + return match; + } + + // 3. Try to match after stripping the "www" subdomain. + rawUrl = rawUrl.replace( /^www\./, '' ); + match = rawUrl.match( pattern ); + + if ( match ) { + return match; + } + + return null; + } +} + +/** + * Represents media defined by the provider configuration. + * + * It can be rendered to the {@link module:engine/view/element~Element view element} and used in the editing or data pipeline. + * + * @private + */ +class Media { + constructor( locale, url, match, previewRenderer ) { + /** + * The URL this Media instance represents. + * + * @member {String} + */ + this.url = this._getValidUrl( url ); + + /** + * Shorthand for {@link module:utils/locale~Locale#t}. + * + * @see module:utils/locale~Locale#t + * @method + */ + this._locale = locale; + + /** + * The output of the `RegExp.match` which validated the {@link #url} of this media. + * + * @member {Object} + */ + this._match = match; + + /** + * The function returning the HTML string preview of this media. + * + * @member {Function} + */ + this._previewRenderer = previewRenderer; + } + + /** + * Returns the view element representation of the media. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {Object} options + * @param {String} [options.elementName] + * @param {Boolean} [options.renderMediaPreview] + * @param {Boolean} [options.renderForEditingView] + * @returns {module:engine/view/element~Element} + */ + getViewElement( writer, options ) { + const attributes = {}; + let viewElement; + + if ( options.renderForEditingView || ( options.renderMediaPreview && this.url && this._previewRenderer ) ) { + if ( this.url ) { + attributes[ 'data-oembed-url' ] = this.url; + } + + if ( options.renderForEditingView ) { + attributes.class = 'ck-media__wrapper'; + } + + const mediaHtml = this._getPreviewHtml( options ); + + viewElement = writer.createRawElement( 'div', attributes, ( domElement, domConverter ) => { + domConverter.setContentOf( domElement, mediaHtml ); + } ); + } else { + if ( this.url ) { + attributes.url = this.url; + } + + viewElement = writer.createEmptyElement( options.elementName, attributes ); + } + + writer.setCustomProperty( 'media-content', true, viewElement ); + + return viewElement; + } + + /** + * Returns the HTML string of the media content preview. + * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer used to produce a view element. + * @param {Object} options + * @param {Boolean} [options.renderForEditingView] + * @returns {String} + */ + _getPreviewHtml( options ) { + if ( this._previewRenderer ) { + return this._previewRenderer( this._match ); + } else { + // The placeholder only makes sense for editing view and media which have URLs. + // Placeholder is never displayed in data and URL-less media have no content. + if ( this.url && options.renderForEditingView ) { + return this._getPlaceholderHtml(); + } + + return ''; + } + } + + /** + * Returns the placeholder HTML when the media has no content preview. + * + * @returns {String} + */ + _getPlaceholderHtml() { + const icon = new IconView(); + const t = this._locale.t; + + icon.content = mediaPlaceholderIcon; + icon.viewBox = mediaPlaceholderIconViewBox; + + const placeholder = new Template( { + tag: 'div', + attributes: { + class: 'ck ck-reset_all ck-media__placeholder' + }, + children: [ + { + tag: 'div', + attributes: { + class: 'ck-media__placeholder__icon' + }, + children: [ icon ] + }, + { + tag: 'a', + attributes: { + class: 'ck-media__placeholder__url', + target: '_blank', + rel: 'noopener noreferrer', + href: this.url, + 'data-cke-tooltip-text': t( 'Open media in new tab' ) + }, + children: [ + { + tag: 'span', + attributes: { + class: 'ck-media__placeholder__url__text' + }, + children: [ this.url ] + } + ] + } + ] + } ).render(); + + return placeholder.outerHTML; + } + + /** + * Returns the full URL to the specified media. + * + * @param {String} url The URL of the media. + * @returns {String|null} + */ + _getValidUrl( url ) { + if ( !url ) { + return null; + } + + if ( url.match( /^https?/ ) ) { + return url; + } + + return 'https://' + url; + } +} diff --git a/packages/ckeditor5-media-embed/_src/ui/mediaformview.js b/packages/ckeditor5-media-embed/_src/ui/mediaformview.js new file mode 100644 index 00000000000..9cc337a9ac8 --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/ui/mediaformview.js @@ -0,0 +1,352 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/ui/mediaformview + */ + +import { + ButtonView, + FocusCycler, + LabeledFieldView, + View, + ViewCollection, + createLabeledInputText, + injectCssTransitionDisabler, + submitHandler +} from 'ckeditor5/src/ui'; +import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils'; +import { icons } from 'ckeditor5/src/core'; + +// See: #8833. +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.css'; +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.} 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 the 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 value of the URL input. + * + * @member {String} #mediaURLInputValue + * @observable + */ + this.set( 'mediaURLInputValue', '' ); + + /** + * The URL input view. + * + * @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView} + */ + this.urlInputView = this._createUrlInput(); + + /** + * The Save button view. + * + * @member {module:ui/button/buttonview~ButtonView} + */ + this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); + this.saveButtonView.type = 'submit'; + this.saveButtonView.bind( 'isEnabled' ).to( this, 'mediaURLInputValue', value => !!value ); + + /** + * The Cancel button view. + * + * @member {module:ui/button/buttonview~ButtonView} + */ + this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); + + /** + * A collection of views that 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 form validators used by {@link #isValid}. + * + * @readonly + * @protected + * @member {Array.} + */ + this._validators = validators; + + this.setTemplate( { + tag: 'form', + + attributes: { + class: [ + 'ck', + 'ck-media-form', + 'ck-responsive-form' + ], + + tabindex: '-1' + }, + + children: [ + this.urlInputView, + this.saveButtonView, + this.cancelButtonView + ] + } ); + + // When rewriting to typescript try to change it to CssTransitionDisablerMixin. + injectCssTransitionDisabler( this ); + + /** + * The default info text for the {@link #urlInputView}. + * + * @private + * @member {String} #_urlInputViewInfoDefault + */ + + /** + * The info text with an additional tip for the {@link #urlInputView}, + * displayed when the input has some value. + * + * @private + * @member {String} #_urlInputViewInfoTip + */ + } + + /** + * @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. Otherwise, the basic caret movement using the arrow keys will be impossible. + this.keystrokes.set( 'arrowright', stopPropagation ); + this.keystrokes.set( 'arrowleft', stopPropagation ); + this.keystrokes.set( 'arrowup', stopPropagation ); + this.keystrokes.set( 'arrowdown', stopPropagation ); + + // Intercept the `selectstart` event, which is blocked by default because of the default behavior + // of the DropdownView#panelView. + // TODO: blocking `selectstart` in the #panelView should be configurable per–drop–down instance. + this.listenTo( this.urlInputView.element, 'selectstart', ( evt, domEvt ) => { + domEvt.stopPropagation(); + }, { priority: 'high' } ); + } + + /** + * @inheritDoc + */ + destroy() { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * 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 it 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 the DOM. + * + * @type {String} + */ + get url() { + return this.urlInputView.fieldView.element.value.trim(); + } + + set url( url ) { + this.urlInputView.fieldView.element.value = url.trim(); + } + + /** + * Validates the form and returns `false` when some fields are invalid. + * + * @returns {Boolean} + */ + isValid() { + this.resetFormStatus(); + + 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; + } + + /** + * Cleans up the supplementary error and information text of the {@link #urlInputView} + * bringing them back to the state when the form has been displayed for the first time. + * + * See {@link #isValid}. + */ + resetFormStatus() { + this.urlInputView.errorText = null; + this.urlInputView.infoText = this._urlInputViewInfoDefault; + } + + /** + * Creates a labeled input view. + * + * @private + * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView} Labeled input view instance. + */ + _createUrlInput() { + const t = this.locale.t; + + const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText ); + const inputField = labeledInput.fieldView; + + this._urlInputViewInfoDefault = t( 'Paste the media URL in the input.' ); + this._urlInputViewInfoTip = t( 'Tip: Paste the URL into the content to embed faster.' ); + + labeledInput.label = t( 'Media URL' ); + labeledInput.infoText = this._urlInputViewInfoDefault; + + inputField.on( 'input', () => { + // Display the tip text only when there is some value. Otherwise fall back to the default info text. + labeledInput.infoText = inputField.element.value ? this._urlInputViewInfoTip : this._urlInputViewInfoDefault; + this.mediaURLInputValue = inputField.element.value.trim(); + } ); + + return labeledInput; + } + + /** + * Creates a button view. + * + * @private + * @param {String} label The button label. + * @param {String} icon The button 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. by a click on {@link #cancelButtonView}. + * + * @event cancel + */ diff --git a/packages/ckeditor5-media-embed/_src/utils.js b/packages/ckeditor5-media-embed/_src/utils.js new file mode 100644 index 00000000000..ac3518560ca --- /dev/null +++ b/packages/ckeditor5-media-embed/_src/utils.js @@ -0,0 +1,122 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module media-embed/utils + */ + +import { isWidget, toWidget } from 'ckeditor5/src/widget'; + +/** + * 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/downcastwriter~DowncastWriter} 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( 'media', true, viewElement ); + + return toWidget( viewElement, writer, { label } ); +} + +/** + * Returns a media widget editing view element if one is selected. + * + * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection + * @returns {module:engine/view/element~Element|null} + */ +export function getSelectedMediaViewWidget( selection ) { + const viewElement = selection.getSelectedElement(); + + if ( viewElement && isMediaWidget( viewElement ) ) { + return viewElement; + } + + return null; +} + +/** + * Checks if a given view element is a media widget. + * + * @param {module:engine/view/element~Element} viewElement + * @returns {Boolean} + */ +export function isMediaWidget( viewElement ) { + return !!viewElement.getCustomProperty( 'media' ) && isWidget( viewElement ); +} + +/** + * Creates a view element representing the media. Either a "semantic" one for the data pipeline: + * + *
+ * + *
+ * + * or a "non-semantic" (for the editing view pipeline): + * + *
+ *
[ non-semantic media preview for "foo" ]
+ *
+ * + * @param {module:engine/view/downcastwriter~DowncastWriter} writer + * @param {module:media-embed/mediaregistry~MediaRegistry} registry + * @param {String} url + * @param {Object} options + * @param {String} [options.elementName] + * @param {Boolean} [options.useSemanticWrapper] + * @param {Boolean} [options.renderForEditingView] + * @returns {module:engine/view/containerelement~ContainerElement} + */ +export function createMediaFigureElement( writer, registry, url, options ) { + return writer.createContainerElement( 'figure', { class: 'media' }, [ + registry.getMediaViewElement( writer, url, options ), + writer.createSlot() + ] ); +} + +/** + * Returns a selected media element in the model, if any. + * + * @param {module:engine/model/selection~Selection} selection + * @returns {module:engine/model/element~Element|null} + */ +export function getSelectedMediaModelWidget( selection ) { + const selectedElement = selection.getSelectedElement(); + + if ( selectedElement && selectedElement.is( 'element', 'media' ) ) { + return selectedElement; + } + + return null; +} + +/** + * Creates a media element and inserts it into the model. + * + * **Note**: This method will use {@link module:engine/model/model~Model#insertContent `model.insertContent()`} logic of inserting content + * if no `insertPosition` is passed. + * + * @param {module:engine/model/model~Model} model + * @param {String} url An URL of an embeddable media. + * @param {module:engine/model/range~Range} [insertRange] The range to insert the media. If not specified, + * the default behavior of {@link module:engine/model/model~Model#insertContent `model.insertContent()`} will + * be applied. + * @param {Boolean} findOptimalPosition If true it will try to find optimal position to insert media without breaking content + * in which a selection is. + */ +export function insertMedia( model, url, selectable, findOptimalPosition ) { + model.change( writer => { + const mediaElement = writer.createElement( 'media', { url } ); + + model.insertObject( mediaElement, selectable, null, { + setSelection: 'on', + findOptimalPosition + } ); + } ); +}