Skip to content

Commit

Permalink
Merge pull request #13335 from ckeditor/ck/13023-rewrite-media-embed-…
Browse files Browse the repository at this point in the history
…to-typescript

Other (media-embed): Rewrite ckeditor5-media-embed to Typescript. Closes #13023.
  • Loading branch information
arkflpc authored Feb 15, 2023
2 parents a75b1e0 + 305e035 commit 2a8f267
Show file tree
Hide file tree
Showing 32 changed files with 1,987 additions and 44 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 0 additions & 33 deletions packages/ckeditor5-engine/src/model/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>
): ModelRange;
public insertContent(
content: Item | ModelDocumentFragment,
selectable?: Exclude<Selectable, Node>,
...rest: Array<unknown>
): ModelRange;

/**
* Inserts content at the position in the editor specified by the selection, as one would expect the paste
* functionality to work.
Expand Down Expand Up @@ -547,27 +535,6 @@ export default class Model extends ObservableMixin() {
return this.fire<ModelInsertContentEvent>( 'insertContent', [ content, selection, placeOrOffset, ...rest ] )!;
}

public insertObject(
element: ModelElement,
selectable: Node,
placeOrOffset: PlaceOrOffset,
options?: {
findOptimalPosition?: 'auto' | 'before' | 'after';
setSelection?: 'on' | 'after';
},
...rest: Array<unknown>
): ModelRange;
public insertObject(
element: ModelElement,
selectable?: Exclude<Selectable, Node>,
placeOrOffset?: null,
options?: {
findOptimalPosition?: 'auto' | 'before' | 'after';
setSelection?: 'on' | 'after';
},
...rest: Array<unknown>
): ModelRange;

/**
* Inserts an {@glink framework/guides/deep-dive/schema#object-elements object element} at a specific position in the editor content.
*
Expand Down
File renamed without changes.
File renamed without changes.
10 changes: 7 additions & 3 deletions packages/ckeditor5-media-embed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
}
}
185 changes: 185 additions & 0 deletions packages/ckeditor5-media-embed/src/automediaembed.ts
Original file line number Diff line number Diff line change
@@ -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 `<media>` 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;
}
}
71 changes: 71 additions & 0 deletions packages/ckeditor5-media-embed/src/converters.ts
Original file line number Diff line number Diff line change
@@ -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
* <figure class="media">
* <oembed url="foo"></oembed>
* </figure>
* ```
*
* or "non-semantic" (for the editing view pipeline):
*
* ```html
* <figure class="media">
* <div data-oembed-url="foo">[ non-semantic media preview for "foo" ]</div>
* </figure>
* ```
*
* **Note:** Changing the model "url" attribute replaces the entire content of the
* `<figure>` 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<DowncastAttributeEvent> = ( 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<DowncastAttributeEvent>( 'attribute:url:media', converter );
};
}
14 changes: 14 additions & 0 deletions packages/ckeditor5-media-embed/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit 2a8f267

Please sign in to comment.