Skip to content

Commit ec4d39d

Browse files
authored
Merge pull request #10226 from ckeditor/ck/9918
Feature (html-support): Added General HTML Support integration for Media Embed feature. Closes #9918.
2 parents a451079 + c8f57d3 commit ec4d39d

File tree

7 files changed

+1417
-1
lines changed

7 files changed

+1417
-1
lines changed

packages/ckeditor5-html-support/src/generalhtmlsupport.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { Plugin } from 'ckeditor5/src/core';
1111

1212
import DataFilter from './datafilter';
13+
import MediaEmbedElementSupport from './integrations/mediaembed';
1314
import TableElementSupport from './integrations/table';
1415
import CodeBlockElementSupport from './integrations/codeblock';
1516
import DualContentModelElementSupport from './integrations/dualcontent';
@@ -38,7 +39,8 @@ export default class GeneralHtmlSupport extends Plugin {
3839
DataFilter,
3940
TableElementSupport,
4041
CodeBlockElementSupport,
41-
DualContentModelElementSupport
42+
DualContentModelElementSupport,
43+
MediaEmbedElementSupport
4244
];
4345
}
4446

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
3+
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4+
*/
5+
6+
/**
7+
* @module html-support/integrations/mediaembed
8+
*/
9+
10+
import { Plugin } from 'ckeditor5/src/core';
11+
12+
import { disallowedAttributesConverter } from '../converters';
13+
import { setViewAttributes } from '../conversionutils.js';
14+
import DataFilter from '../datafilter';
15+
import DataSchema from '../dataschema';
16+
17+
/**
18+
* Provides the General HTML Support integration with {@link module:media-embed/mediaembed~MediaEmbed Media Embed} feature.
19+
*
20+
* @extends module:core/plugin~Plugin
21+
*/
22+
export default class MediaEmbedElementSupport extends Plugin {
23+
static get requires() {
24+
return [ DataFilter ];
25+
}
26+
27+
init() {
28+
const editor = this.editor;
29+
30+
// Stop here if MediaEmbed plugin is not provided or the integrator wants to output markup with previews as
31+
// we do not support filtering previews.
32+
if ( !editor.plugins.has( 'MediaEmbed' ) || editor.config.get( 'mediaEmbed.previewsInData' ) ) {
33+
return;
34+
}
35+
36+
const schema = editor.model.schema;
37+
const conversion = editor.conversion;
38+
const dataFilter = this.editor.plugins.get( DataFilter );
39+
const dataSchema = this.editor.plugins.get( DataSchema );
40+
const mediaElementName = editor.config.get( 'mediaEmbed.elementName' );
41+
42+
// Overwrite GHS schema definition for a given elementName.
43+
dataSchema.registerBlockElement( {
44+
model: 'media',
45+
view: mediaElementName
46+
} );
47+
48+
dataFilter.on( `register:${ mediaElementName }`, ( evt, definition ) => {
49+
if ( definition.model !== 'media' ) {
50+
return;
51+
}
52+
53+
schema.extend( 'media', {
54+
allowAttributes: [
55+
'htmlAttributes',
56+
'htmlFigureAttributes'
57+
]
58+
} );
59+
60+
conversion.for( 'upcast' ).add( disallowedAttributesConverter( definition, dataFilter ) );
61+
conversion.for( 'upcast' ).add( viewToModelMediaAttributesConverter( dataFilter, mediaElementName ) );
62+
conversion.for( 'dataDowncast' ).add( modelToViewMediaAttributeConverter( mediaElementName ) );
63+
64+
evt.stop();
65+
} );
66+
}
67+
}
68+
69+
function viewToModelMediaAttributesConverter( dataFilter, mediaElementName ) {
70+
return dispatcher => {
71+
// Here we want to be the first to convert (and consume) the figure element, otherwise GHS can pick it up and
72+
// convert it to generic `htmlFigure`.
73+
dispatcher.on( 'element:figure', upcastFigure, { priority: 'high' } );
74+
75+
// Handle media elements without `<figure>` container.
76+
dispatcher.on( `element:${ mediaElementName }`, upcastMedia );
77+
};
78+
79+
function upcastFigure( evt, data, conversionApi ) {
80+
const viewFigureElement = data.viewItem;
81+
82+
// Convert only "media figure" elements.
83+
if ( !conversionApi.consumable.test( viewFigureElement, { name: true, classes: 'media' } ) ) {
84+
return;
85+
}
86+
87+
// Find media element.
88+
const viewMediaElement = Array.from( viewFigureElement.getChildren() )
89+
.find( item => item.is( 'element', mediaElementName ) );
90+
91+
// Do not convert if media element is absent.
92+
if ( !viewMediaElement ) {
93+
return;
94+
}
95+
96+
// Convert just the media element.
97+
Object.assign( data, conversionApi.convertItem( viewMediaElement, data.modelCursor ) );
98+
99+
preserveElementAttributes( viewMediaElement, 'htmlAttributes' );
100+
preserveElementAttributes( viewFigureElement, 'htmlFigureAttributes' );
101+
102+
// Consume the figure to prevent converting it to `htmlFigure` by default GHS converters.
103+
conversionApi.consumable.consume( viewFigureElement, { name: true } );
104+
105+
function preserveElementAttributes( viewElement, attributeName ) {
106+
const viewAttributes = dataFilter._consumeAllowedAttributes( viewElement, conversionApi );
107+
108+
if ( viewAttributes ) {
109+
conversionApi.writer.setAttribute( attributeName, viewAttributes, data.modelRange );
110+
}
111+
}
112+
}
113+
114+
function upcastMedia( evt, data, conversionApi ) {
115+
const viewMediaElement = data.viewItem;
116+
const viewAttributes = dataFilter._consumeAllowedAttributes( viewMediaElement, conversionApi );
117+
118+
if ( viewAttributes ) {
119+
conversionApi.writer.setAttribute( 'htmlAttributes', viewAttributes, data.modelRange );
120+
}
121+
}
122+
}
123+
124+
function modelToViewMediaAttributeConverter( mediaElementName ) {
125+
return dispatcher => {
126+
addAttributeConversionDispatcherHandler( mediaElementName, 'htmlAttributes' );
127+
addAttributeConversionDispatcherHandler( 'figure', 'htmlFigureAttributes' );
128+
129+
function addAttributeConversionDispatcherHandler( elementName, attributeName ) {
130+
dispatcher.on( `attribute:${ attributeName }:media`, ( evt, data, conversionApi ) => {
131+
if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
132+
return;
133+
}
134+
135+
const containerElement = conversionApi.mapper.toViewElement( data.item );
136+
const viewElement = getDescendantElement( conversionApi, containerElement, elementName );
137+
138+
setViewAttributes( conversionApi.writer, data.attributeNewValue, viewElement );
139+
} );
140+
}
141+
};
142+
}
143+
144+
// Returns the first view element descendant matching the given view name.
145+
// Includes view element itself.
146+
//
147+
// @private
148+
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
149+
// @param {module:engine/view/element~Element} containerElement
150+
// @param {String} elementName
151+
// @returns {module:engine/view/element~Element|null}
152+
function getDescendantElement( conversionApi, containerElement, elementName ) {
153+
const range = conversionApi.writer.createRangeOn( containerElement );
154+
155+
for ( const { item } of range.getWalker() ) {
156+
if ( item.is( 'element', elementName ) ) {
157+
return item;
158+
}
159+
}
160+
}

packages/ckeditor5-html-support/src/schemadefinitions.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,14 @@ export default {
788788
inheritAllFrom: '$htmlObjectInline'
789789
}
790790
},
791+
{
792+
model: 'htmlOembed',
793+
view: 'oembed',
794+
isObject: true,
795+
modelSchema: {
796+
inheritAllFrom: '$htmlObjectInline'
797+
}
798+
},
791799
{
792800
model: 'htmlAudio',
793801
view: 'audio',
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<head>
2+
<!--
3+
For a totally unknown reason, Travis and Vimeo do not like each other and the test fail on CI.
4+
Additionally, the embedded Spotify player has started to throw an uncaught exception, but only on CI. See #9678.
5+
-->
6+
<meta name="x-cke-crawler-ignore-patterns" content='{
7+
"request-failure": "vimeo.com",
8+
"response-failure": "vimeo.com",
9+
"console-error": [ "<svg> attribute preserveAspectRatio", "vimeo.com" ],
10+
"uncaught-exception": "retargetingPixels is not defined"
11+
}'>
12+
</head>
13+
14+
<div style="display: flex; justify-content: space-between;">
15+
<div style="flex: 0 1 49%">
16+
<h4>The default <code>oembed</code> element</h4>
17+
18+
<div id="editor">
19+
<figure class="media allowed-class disallowed-class" data-validation-allow="Allowed attribute"
20+
data-validation-disallow="Disallowed attribute" style="color: blue;">
21+
<oembed class="allowed-class disallowed-class" url="https://www.youtube.com/watch?v=ZVv7UMQPEWk"
22+
data-validation-allow="Allowed attribute" data-validation-disallow="Disallowed attribute"
23+
style="color: blue;"></oembed>
24+
</figure>
25+
</div>
26+
27+
<h4>Expected output</h4>
28+
29+
<textarea style="width: 100%; height: 200px; line-height: 1.8em;">
30+
<figure class="media allowed-class" style="color:blue;" data-validation-allow="Allowed attribute">
31+
<oembed class="allowed-class" style="color:blue;" url="https://www.youtube.com/watch?v=ZVv7UMQPEWk" data-validation-allow="Allowed attribute"></oembed>
32+
</figure>
33+
</textarea>
34+
35+
</div>
36+
<div style="flex: 0 1 49%;">
37+
<h4>Custom media element</h4>
38+
39+
<div id="editor-custom-element-name">
40+
<figure class="media allowed-class disallowed-class" data-validation-allow="Allowed attribute"
41+
data-validation-disallow="Disallowed attribute" style="color: blue;">
42+
<custom-oembed class="allowed-class disallowed-class" url="https://vimeo.com/1084537"
43+
data-validation-allow="Allowed attribute" data-validation-disallow="Disallowed attribute"
44+
style="color: blue;"></custom-oembed>
45+
</figure>
46+
</div>
47+
48+
<h4>Expected output</h4>
49+
50+
<textarea style="width: 100%; height: 200px; line-height: 1.8em;">
51+
<figure class="media allowed-class" style="color:blue;" data-validation-allow="Allowed attribute">
52+
<custom-oembed class="allowed-class" style="color:blue;" url="https://vimeo.com/1084537" data-validation-allow="Allowed attribute"></custom-oembed>
53+
</figure>
54+
</textarea>
55+
56+
</div>
57+
</div>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
3+
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4+
*/
5+
6+
/* globals console:false, window, document */
7+
8+
import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
9+
import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
10+
import MediaEmbedToolbar from '@ckeditor/ckeditor5-media-embed/src/mediaembedtoolbar';
11+
import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting';
12+
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
13+
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
14+
15+
import GeneralHtmlSupport from '../../src/generalhtmlsupport';
16+
17+
ClassicEditor
18+
.create( document.querySelector( '#editor' ), {
19+
plugins: [
20+
GeneralHtmlSupport,
21+
Essentials,
22+
Paragraph,
23+
SourceEditing,
24+
MediaEmbed,
25+
MediaEmbedToolbar
26+
],
27+
image: { toolbar: [ 'toggleImageCaption', 'imageTextAlternative' ] },
28+
toolbar: [ 'mediaEmbed', '|', 'sourceEditing' ],
29+
mediaEmbed: {
30+
toolbar: [ 'mediaEmbed' ]
31+
},
32+
htmlSupport: {
33+
allow: [
34+
{
35+
name: /^(figure|oembed)$/,
36+
attributes: [ 'data-validation-allow' ],
37+
classes: [ 'allowed-class' ],
38+
styles: {
39+
color: 'blue'
40+
}
41+
}
42+
],
43+
disallow: [
44+
{
45+
name: /^(figure|oembed)$/,
46+
attributes: 'data-validation-disallow',
47+
classes: [ 'disallowed-class' ],
48+
styles: {
49+
color: 'red'
50+
}
51+
}
52+
]
53+
}
54+
} )
55+
.then( editor => {
56+
window.editor = editor;
57+
} )
58+
.catch( err => {
59+
console.error( err.stack );
60+
} );
61+
62+
ClassicEditor
63+
.create( document.querySelector( '#editor-custom-element-name' ), {
64+
plugins: [
65+
GeneralHtmlSupport,
66+
Essentials,
67+
Paragraph,
68+
SourceEditing,
69+
MediaEmbed,
70+
MediaEmbedToolbar
71+
],
72+
image: { toolbar: [ 'toggleImageCaption', 'imageTextAlternative' ] },
73+
toolbar: [ 'mediaEmbed', '|', 'sourceEditing' ],
74+
mediaEmbed: {
75+
elementName: 'custom-oembed',
76+
toolbar: [ 'mediaEmbed' ]
77+
},
78+
htmlSupport: {
79+
allow: [
80+
{
81+
name: /^(figure|custom-oembed)$/,
82+
attributes: [ 'data-validation-allow' ],
83+
classes: [ 'allowed-class' ],
84+
styles: {
85+
color: 'blue'
86+
}
87+
}
88+
],
89+
disallow: [
90+
{
91+
name: /^(figure|custom-oembed)$/,
92+
attributes: 'data-validation-disallow',
93+
classes: [ 'disallowed-class' ],
94+
styles: {
95+
color: 'red'
96+
}
97+
}
98+
]
99+
}
100+
} )
101+
.then( editor => {
102+
window.editor = editor;
103+
} )
104+
.catch( err => {
105+
console.error( err.stack );
106+
} );
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## Media Embed
2+
3+
Compare the output (using e.g. Source Editing plugin) with the expected result below the editor.

0 commit comments

Comments
 (0)