Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite media-embed to typescript. #13335

Merged
merged 12 commits into from
Feb 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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' {
mremiszewski marked this conversation as resolved.
Show resolved Hide resolved
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