diff --git a/.gitignore b/.gitignore index 4d3e13f7fbe..985658256fe 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ packages/ckeditor5-language/src/**/*.js packages/ckeditor5-link/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-engine/src/model/model.ts b/packages/ckeditor5-engine/src/model/model.ts index 630dd1e5408..6b962486701 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/automediaembed.js b/packages/ckeditor5-media-embed/_src/automediaembed.js similarity index 100% rename from packages/ckeditor5-media-embed/src/automediaembed.js rename to packages/ckeditor5-media-embed/_src/automediaembed.js diff --git a/packages/ckeditor5-media-embed/src/converters.js b/packages/ckeditor5-media-embed/_src/converters.js similarity index 100% rename from packages/ckeditor5-media-embed/src/converters.js rename to packages/ckeditor5-media-embed/_src/converters.js diff --git a/packages/ckeditor5-media-embed/src/index.js b/packages/ckeditor5-media-embed/_src/index.js similarity index 100% rename from packages/ckeditor5-media-embed/src/index.js rename to packages/ckeditor5-media-embed/_src/index.js diff --git a/packages/ckeditor5-media-embed/src/mediaembed.js b/packages/ckeditor5-media-embed/_src/mediaembed.js similarity index 100% rename from packages/ckeditor5-media-embed/src/mediaembed.js rename to packages/ckeditor5-media-embed/_src/mediaembed.js diff --git a/packages/ckeditor5-media-embed/src/mediaembedcommand.js b/packages/ckeditor5-media-embed/_src/mediaembedcommand.js similarity index 100% rename from packages/ckeditor5-media-embed/src/mediaembedcommand.js rename to packages/ckeditor5-media-embed/_src/mediaembedcommand.js diff --git a/packages/ckeditor5-media-embed/src/mediaembedediting.js b/packages/ckeditor5-media-embed/_src/mediaembedediting.js similarity index 100% rename from packages/ckeditor5-media-embed/src/mediaembedediting.js rename to packages/ckeditor5-media-embed/_src/mediaembedediting.js diff --git a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.js b/packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js similarity index 100% rename from packages/ckeditor5-media-embed/src/mediaembedtoolbar.js rename to packages/ckeditor5-media-embed/_src/mediaembedtoolbar.js diff --git a/packages/ckeditor5-media-embed/src/mediaembedui.js b/packages/ckeditor5-media-embed/_src/mediaembedui.js similarity index 100% rename from packages/ckeditor5-media-embed/src/mediaembedui.js rename to packages/ckeditor5-media-embed/_src/mediaembedui.js diff --git a/packages/ckeditor5-media-embed/src/mediaregistry.js b/packages/ckeditor5-media-embed/_src/mediaregistry.js similarity index 100% rename from packages/ckeditor5-media-embed/src/mediaregistry.js rename to packages/ckeditor5-media-embed/_src/mediaregistry.js diff --git a/packages/ckeditor5-media-embed/src/ui/mediaformview.js b/packages/ckeditor5-media-embed/_src/ui/mediaformview.js similarity index 100% rename from packages/ckeditor5-media-embed/src/ui/mediaformview.js rename to packages/ckeditor5-media-embed/_src/ui/mediaformview.js diff --git a/packages/ckeditor5-media-embed/src/utils.js b/packages/ckeditor5-media-embed/_src/utils.js similarity index 100% rename from packages/ckeditor5-media-embed/src/utils.js rename to packages/ckeditor5-media-embed/_src/utils.js diff --git a/packages/ckeditor5-media-embed/package.json b/packages/ckeditor5-media-embed/package.json index b53bcae7886..fa1d728b7a5 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.1", "ckeditor5": "^36.0.1" @@ -32,6 +32,7 @@ "@ckeditor/ckeditor5-undo": "^36.0.1", "@ckeditor/ckeditor5-utils": "^36.0.1", "@ckeditor/ckeditor5-widget": "^36.0.1", + "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.ts b/packages/ckeditor5-media-embed/src/automediaembed.ts new file mode 100644 index 00000000000..1a46e88f6cb --- /dev/null +++ b/packages/ckeditor5-media-embed/src/automediaembed.ts @@ -0,0 +1,185 @@ +/** + * @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 { type Editor, Plugin, type PluginDependencies } from 'ckeditor5/src/core'; +import { LiveRange, LivePosition } from 'ckeditor5/src/engine'; +import { Clipboard, type ClipboardPipeline } from 'ckeditor5/src/clipboard'; +import { Delete } from 'ckeditor5/src/typing'; +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-.~:/?#[\]@!$&'()*+,;=%]+$/; + +/** + * The auto-media embed plugin. It recognizes media links in the pasted content and embeds + * them shortly after they are injected into the document. + */ +export default class AutoMediaEmbed extends Plugin { + /** + * @inheritDoc + */ + public static get requires(): PluginDependencies { + return [ Clipboard, Delete, Undo ]; + } + + /** + * @inheritDoc + */ + 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: Editor ) { + super( editor ); + + this._timeoutId = null; + this._positionToInsert = null; + } + + /** + * @inheritDoc + */ + public init(): void { + 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. + const clipboardPipeline: ClipboardPipeline = editor.plugins.get( 'ClipboardPipeline' ); + this.listenTo( 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' } ); + } ); + + const undoCommand: UndoCommand = editor.commands.get( 'undo' )!; + undoCommand.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. + * + * @param leftPosition Left position of the selection. + * @param rightPosition Right position of the selection. + */ + 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. + 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: 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: 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' ) { + insertionPosition = this._positionToInsert; + } + + insertMedia( editor.model, url, insertionPosition, false ); + + this._positionToInsert!.detach(); + this._positionToInsert = null; + } ); + + editor.plugins.get( Delete ).requestUndoOnBackspace(); + }, 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 new file mode 100644 index 00000000000..7b2b4741b83 --- /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 + */ + +/** + * @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'; + +/** + * 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.ts b/packages/ckeditor5-media-embed/src/index.ts new file mode 100644 index 00000000000..ecd4a3f22eb --- /dev/null +++ b/packages/ckeditor5-media-embed/src/index.ts @@ -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.ts b/packages/ckeditor5-media-embed/src/mediaembed.ts new file mode 100644 index 00000000000..d5add41200d --- /dev/null +++ b/packages/ckeditor5-media-embed/src/mediaembed.ts @@ -0,0 +1,50 @@ +/** + * @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 type { ArrayOrItem } from 'ckeditor5/src/utils'; + +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}. + */ +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'; + } +} +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ MediaEmbed.pluginName ]: MediaEmbed; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaembedcommand.ts b/packages/ckeditor5-media-embed/src/mediaembedcommand.ts new file mode 100644 index 00000000000..d538bcc2280 --- /dev/null +++ b/packages/ckeditor5-media-embed/src/mediaembedcommand.ts @@ -0,0 +1,98 @@ +/** + * @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 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'; + +/** + * 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: + * + * ```ts + * editor.execute( 'mediaEmbed', 'http://url.to.the/media' ); + * ``` + */ +export default class MediaEmbedCommand extends Command { + /** + * Media url. + */ + declare public value: string | undefined; + + /** + * @inheritDoc + */ + public override refresh(): void { + const model = this.editor.model; + const selection = model.document.selection; + const selectedMedia = getSelectedMediaModelWidget( selection ); + + this.value = selectedMedia ? selectedMedia.getAttribute( 'url' ) as string : undefined; + + 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 url The URL of the media. + */ + public override execute( url: string ): void { + 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 media embed is allowed in the parent. + */ +function isAllowedInParent( selection: Selection | DocumentSelection, model: Model ): boolean { + const insertionRange = findOptimalInsertionRange( selection, model ); + 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 as Element; + } + + return model.schema.checkChild( parent, 'media' ); +} + +/** + * Checks if the media object is selected. + */ +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 new file mode 100644 index 00000000000..e2aa2a54b5d --- /dev/null +++ b/packages/ckeditor5-media-embed/src/mediaembedediting.ts @@ -0,0 +1,289 @@ +/** + * @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, 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 './mediaembedconfig'; +import MediaEmbedCommand from './mediaembedcommand'; +import MediaRegistry from './mediaregistry'; +import { toMediaWidget, createMediaFigureElement } from './utils'; + +import '../theme/mediaembedediting.css'; + +/** + * The media embed editing feature. + */ +export default class MediaEmbedEditing extends Plugin { + /** + * @inheritDoc + */ + public static get pluginName(): 'MediaEmbedEditing' { + return 'MediaEmbedEditing'; + } + + /** + * The media registry managing the media providers in the editor. + */ + public registry: MediaRegistry; + + /** + * @inheritDoc + */ + constructor( editor: 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/ + } + ] + } as MediaEmbedConfig ); + + this.registry = new MediaRegistry( editor.locale, editor.config.get( 'mediaEmbed' )! ); + } + + /** + * @inheritDoc + */ + 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 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' ) as string; + + 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' ) as string; + 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' ) as string; + + if ( registry.hasMedia( url ) ) { + return writer.createElement( 'media', { url } ); + } + + return null; + } + } ) + // Upcast non-semantic media. + .elementToElement( { + view: { + name: 'div', + attributes: { + 'data-oembed-url': true + } + }, + model: ( viewMedia, { writer } ) => { + 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 => { + const converter: GetCallback = ( 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' } ); + } + }; + + dispatcher.on( 'element:figure', converter ); + } ); + } +} + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ MediaEmbedEditing.pluginName ]: MediaEmbedEditing; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts new file mode 100644 index 00000000000..1256c8c09f9 --- /dev/null +++ b/packages/ckeditor5-media-embed/src/mediaembedtoolbar.ts @@ -0,0 +1,57 @@ +/** + * @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, 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. + * + * Instances of toolbar components (e.g. buttons) are created based on the + * {@link module:media-embed/mediaembed~MediaEmbedConfig#toolbar `media.toolbar` configuration option}. + */ +export default class MediaEmbedToolbar extends Plugin { + /** + * @inheritDoc + */ + public static get requires(): PluginDependencies { + return [ WidgetToolbarRepository ]; + } + + /** + * @inheritDoc + */ + public static get pluginName(): 'MediaEmbedToolbar' { + return 'MediaEmbedToolbar'; + } + + /** + * @inheritDoc + */ + 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' ) || [], + getRelatedElement: getSelectedMediaViewWidget + } ); + } +} + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ MediaEmbedToolbar.pluginName ]: MediaEmbedToolbar; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaembedui.ts b/packages/ckeditor5-media-embed/src/mediaembedui.ts new file mode 100644 index 00000000000..89b6c257d11 --- /dev/null +++ b/packages/ckeditor5-media-embed/src/mediaembedui.ts @@ -0,0 +1,129 @@ +/** + * @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, 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. + */ +export default class MediaEmbedUI extends Plugin { + /** + * @inheritDoc + */ + public static get requires(): PluginDependencies { + return [ MediaEmbedEditing ]; + } + + /** + * @inheritDoc + */ + public static get pluginName(): 'MediaEmbedUI' { + return 'MediaEmbedUI'; + } + + /** + * @inheritDoc + */ + public init(): void { + const editor = this.editor; + const command: MediaEmbedCommand = editor.commands.get( 'mediaEmbed' )!; + + editor.ui.componentFactory.add( 'mediaEmbed', locale => { + const dropdown = createDropdown( locale ); + + this._setUpDropdown( dropdown, command ); + + return dropdown; + } ); + } + + 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 ( CssTransitionDisablerMixin( 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.fieldView.bind( 'value' ).to( command, 'value' ); + + // Form elements should be read-only when corresponding commands are disabled. + form.urlInputView.bind( 'isEnabled' ).to( command, 'isEnabled' ); + } ); + + dropdown.bind( 'isEnabled' ).to( command ); + + button.set( { + label: t( 'Insert media' ), + icon: mediaIcon, + tooltip: true + } ); + } +} + +function getFormValidators( t: LocaleTranslate, registry: MediaRegistry ): Array<( v: MediaFormView ) => string | undefined> { + 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.' ); + } + } + ]; +} + +declare module '@ckeditor/ckeditor5-core' { + interface PluginsMap { + [ MediaEmbedUI.pluginName ]: MediaEmbedUI; + } +} diff --git a/packages/ckeditor5-media-embed/src/mediaregistry.ts b/packages/ckeditor5-media-embed/src/mediaregistry.ts new file mode 100644 index 00000000000..fb7a4e2e58a --- /dev/null +++ b/packages/ckeditor5-media-embed/src/mediaregistry.ts @@ -0,0 +1,318 @@ +/** + * @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 type { DowncastWriter, ViewElement } from 'ckeditor5/src/engine'; +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 './mediaembedconfig'; +import type { MediaOptions } from './utils'; + +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 { + /** + * 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 locale The localization services instance. + * @param config The configuration of the media embed feature. + */ + constructor( locale: Locale, config: MediaEmbedConfig ) { + 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 ); + } ); + + this.locale = locale; + this.providerDefinitions = providerDefinitions; + } + + /** + * Checks whether the passed URL is representing a certain media type allowed in the editor. + * + * @param url The URL to be checked + */ + public hasMedia( url: string ): boolean { + 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 writer The view writer used to produce a view element. + * @param url The URL to be translated into a view element. + */ + public getMediaViewElement( + writer: DowncastWriter, + url: string, + options: MediaOptions + ): ViewElement { + return this._getMedia( url )!.getViewElement( writer, options ); + } + + /** + * Returns a `Media` instance for the given URL. + * + * @param url The URL of the media. + * @returns The `Media` instance or `null` when there is none. + */ + private _getMedia( url: string ): Media | null { + 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`. + * + * @param url The URL of the media. + * @param pattern The pattern that should accept the media URL. + */ + private _getUrlMatches( url: string, pattern: RegExp ): RegExpMatchArray | null { + // 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. + */ +class Media { + /** + * The URL this Media instance represents. + */ + public url: string | null; + + /** + * Shorthand for {@link module:utils/locale~Locale#t}. + * + * @see module:utils/locale~Locale#t + */ + private _locale: Locale; + + /** + * The output of the `RegExp.match` which validated the {@link #url} of this media. + */ + private _match?: RegExpMatchArray; + + /** + * The function returning the HTML string preview of this media. + */ + private _previewRenderer?: ( match: RegExpMatchArray ) => string; + + constructor( locale: Locale, url?: string, match?: RegExpMatchArray, previewRenderer?: ( match: RegExpMatchArray ) => string ) { + this.url = this._getValidUrl( url ); + this._locale = locale; + this._match = match; + this._previewRenderer = previewRenderer; + } + + /** + * Returns the view element representation of the media. + * + * @param writer The view writer used to produce a view element. + */ + public getViewElement( + writer: DowncastWriter, + options: MediaOptions + ): ViewElement { + const attributes: Record = {}; + 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. + */ + private _getPreviewHtml( options: { renderForEditingView?: boolean } ): string { + 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. + */ + private _getPlaceholderHtml(): string { + 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() as HTMLElement; + + return placeholder.outerHTML; + } + + /** + * Returns the full URL to the specified media. + * + * @param url The URL of the media. + */ + private _getValidUrl( url: string | undefined ): string | null { + if ( !url ) { + return null; + } + + if ( url.match( /^https?/ ) ) { + return url; + } + + return 'https://' + url; + } +} diff --git a/packages/ckeditor5-media-embed/src/ui/mediaformview.ts b/packages/ckeditor5-media-embed/src/ui/mediaformview.ts new file mode 100644 index 00000000000..6dd41894a0e --- /dev/null +++ b/packages/ckeditor5-media-embed/src/ui/mediaformview.ts @@ -0,0 +1,312 @@ +/** + * @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 { + type InputTextView, + ButtonView, + FocusCycler, + LabeledFieldView, + View, + ViewCollection, + createLabeledInputText, + submitHandler +} from 'ckeditor5/src/ui'; +import { FocusTracker, KeystrokeHandler, type Locale } 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}. + */ +export default class MediaFormView extends View { + /** + * Tracks information about the DOM focus in the form. + */ + public readonly focusTracker: FocusTracker; + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + */ + 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. + */ + private readonly _focusables: ViewCollection; + + /** + * Helps cycling over {@link #_focusables} in the form. + */ + private readonly _focusCycler: FocusCycler; + + /** + * An array of form validators used by {@link #isValid}. + */ + private 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; + + this.focusTracker = new FocusTracker(); + this.keystrokes = new KeystrokeHandler(); + this.set( 'mediaURLInputValue', '' ); + this.urlInputView = this._createUrlInput(); + + this.saveButtonView = this._createButton( t( 'Save' ), icons.check, 'ck-button-save' ); + this.saveButtonView.type = 'submit'; + this.saveButtonView.bind( 'isEnabled' ).to( this, 'mediaURLInputValue', value => !!value ); + + this.cancelButtonView = this._createButton( t( 'Cancel' ), icons.cancel, 'ck-button-cancel', 'cancel' ); + + this._focusables = new ViewCollection(); + + 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' + } + } ); + + 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 + ] + } ); + } + + /** + * @inheritDoc + */ + public override render(): void { + 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: 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 + // 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 + */ + public override destroy(): void { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Focuses the fist {@link #_focusables} in the form. + */ + public focus(): void { + 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. + */ + public get url(): string { + return this.urlInputView.fieldView.element!.value.trim(); + } + + public set url( url: string ) { + this.urlInputView.fieldView.element!.value = url.trim(); + } + + /** + * Validates the form and returns `false` when some fields are invalid. + */ + public isValid(): boolean { + 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}. + */ + public resetFormStatus(): void { + this.urlInputView.errorText = null; + this.urlInputView.infoText = this._urlInputViewInfoDefault!; + } + + /** + * Creates a labeled input view. + * + * @returns Labeled input view instance. + */ + private _createUrlInput(): LabeledFieldView { + 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. + * + * @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. + */ + private _createButton( label: string, icon: string, className: string, eventName?: string ): ButtonView { + 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; + } +} diff --git a/packages/ckeditor5-media-embed/src/utils.ts b/packages/ckeditor5-media-embed/src/utils.ts new file mode 100644 index 00000000000..f8b5fcf58a6 --- /dev/null +++ b/packages/ckeditor5-media-embed/src/utils.ts @@ -0,0 +1,128 @@ +/** + * @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() + ] ); +} + +/** + * 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 + } ); + } ); +} + +/** + * Type for commonly grouped function parameters across this package. + */ +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-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';