@ckeditor/ckeditor5-html-support
Version:
HTML Support feature for CKEditor 5.
1,662 lines (1,657 loc) • 169 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Plugin } from '@ckeditor/ckeditor5-core/dist/index.js';
import { toArray, priorities, CKEditorError, isValidAttributeName, uid, logWarning, global } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { Matcher, StylesMap, ViewUpcastWriter, HtmlDataProcessor } from '@ckeditor/ckeditor5-engine/dist/index.js';
import { toWidget, Widget } from '@ckeditor/ckeditor5-widget/dist/index.js';
import { cloneDeep, startCase, mergeWith, isPlainObject, isEqual } from 'es-toolkit/compat';
import { Enter } from '@ckeditor/ckeditor5-enter/dist/index.js';
/**
* Helper function for the downcast converter. Updates attributes on the given view element.
*
* @param writer The view writer.
* @param oldViewAttributes The previous GHS attribute value.
* @param newViewAttributes The current GHS attribute value.
* @param viewElement The view element to update.
* @internal
*/ function updateViewAttributes(writer, oldViewAttributes, newViewAttributes, viewElement) {
if (oldViewAttributes) {
removeViewAttributes(writer, oldViewAttributes, viewElement);
}
if (newViewAttributes) {
setViewAttributes(writer, newViewAttributes, viewElement);
}
}
/**
* Helper function for the downcast converter. Sets attributes on the given view element.
*
* @param writer The view writer.
* @param viewAttributes The GHS attribute value.
* @param viewElement The view element to update.
* @internal
*/ function setViewAttributes(writer, viewAttributes, viewElement) {
if (viewAttributes.attributes) {
for (const [key, value] of Object.entries(viewAttributes.attributes)){
writer.setAttribute(key, value, viewElement);
}
}
if (viewAttributes.styles) {
writer.setStyle(viewAttributes.styles, viewElement);
}
if (viewAttributes.classes) {
writer.addClass(viewAttributes.classes, viewElement);
}
}
/**
* Helper function for the downcast converter. Removes attributes on the given view element.
*
* @param writer The view writer.
* @param viewAttributes The GHS attribute value.
* @param viewElement The view element to update.
* @internal
*/ function removeViewAttributes(writer, viewAttributes, viewElement) {
if (viewAttributes.attributes) {
for (const [key] of Object.entries(viewAttributes.attributes)){
writer.removeAttribute(key, viewElement);
}
}
if (viewAttributes.styles) {
for (const style of Object.keys(viewAttributes.styles)){
writer.removeStyle(style, viewElement);
}
}
if (viewAttributes.classes) {
writer.removeClass(viewAttributes.classes, viewElement);
}
}
/**
* Merges view element attribute objects.
*
* @internal
*/ function mergeViewElementAttributes(target, source) {
const result = cloneDeep(target);
let key = 'attributes';
for(key in source){
// Merge classes.
if (key == 'classes') {
result[key] = Array.from(new Set([
...target[key] || [],
...source[key]
]));
} else {
result[key] = {
...target[key],
...source[key]
};
}
}
return result;
}
function modifyGhsAttribute(writer, item, ghsAttributeName, subject, callback) {
const oldValue = item.getAttribute(ghsAttributeName);
const newValue = {};
for (const kind of [
'attributes',
'styles',
'classes'
]){
// Properties other than `subject` should be assigned from `oldValue`.
if (kind != subject) {
if (oldValue && oldValue[kind]) {
newValue[kind] = oldValue[kind];
}
continue;
}
// `callback` should be applied on property [`subject`].
if (subject == 'classes') {
const values = new Set(oldValue && oldValue.classes || []);
callback(values);
if (values.size) {
newValue[kind] = Array.from(values);
}
continue;
}
const values = new Map(Object.entries(oldValue && oldValue[kind] || {}));
callback(values);
if (values.size) {
newValue[kind] = Object.fromEntries(values);
}
}
if (Object.keys(newValue).length) {
if (item.is('documentSelection')) {
writer.setSelectionAttribute(ghsAttributeName, newValue);
} else {
writer.setAttribute(ghsAttributeName, newValue, item);
}
} else if (oldValue) {
if (item.is('documentSelection')) {
writer.removeSelectionAttribute(ghsAttributeName);
} else {
writer.removeAttribute(ghsAttributeName, item);
}
}
}
/**
* Strips the `styles`, and `classes` keys from the GHS attribute value on the given item.
*
* @internal
*/ function removeFormatting(ghsAttributeName, itemRange, writer) {
for (const item of itemRange.getItems({
shallow: true
})){
const value = item.getAttribute(ghsAttributeName);
// Copy only attributes to the new attribute value.
if (value && value.attributes && Object.keys(value.attributes).length) {
// But reset the GHS attribute only when there is anything more than just attributes.
if (Object.keys(value).length > 1) {
writer.setAttribute(ghsAttributeName, {
attributes: value.attributes
}, item);
}
} else {
// There are no attributes, so remove the GHS attribute completely.
writer.removeAttribute(ghsAttributeName, item);
}
}
}
/**
* Transforms passed string to PascalCase format. Examples:
* * `div` => `Div`
* * `h1` => `H1`
* * `table` => `Table`
*
* @internal
*/ function toPascalCase(data) {
return startCase(data).replace(/ /g, '');
}
/**
* Returns the attribute name of the model element that holds raw HTML attributes.
*
* @internal
*/ function getHtmlAttributeName(viewElementName) {
return `html${toPascalCase(viewElementName)}Attributes`;
}
/**
* View-to-model conversion helper for object elements.
*
* Preserves object element content in `htmlContent` attribute.
*
* @returns Returns a conversion callback.
* @internal
*/ function viewToModelObjectConverter({ model: modelName }) {
return (viewElement, conversionApi)=>{
// Let's keep element HTML and its attributes, so we can rebuild element in downcast conversions.
return conversionApi.writer.createElement(modelName, {
htmlContent: viewElement.getCustomProperty('$rawContent')
});
};
}
/**
* Conversion helper converting an object element to an HTML object widget.
*
* @returns Returns a conversion callback.
* @internal
*/ function toObjectWidgetConverter(editor, { view: viewName, isInline }) {
const t = editor.t;
return (modelElement, { writer })=>{
const widgetLabel = t('HTML object');
const viewElement = createObjectView(viewName, modelElement, writer);
const viewAttributes = modelElement.getAttribute(getHtmlAttributeName(viewName));
writer.addClass('html-object-embed__content', viewElement);
if (viewAttributes) {
setViewAttributes(writer, viewAttributes, viewElement);
}
// Widget cannot be a raw element because the widget system would not be able
// to add its UI to it. Thus, we need separate view container.
const viewContainer = writer.createContainerElement(isInline ? 'span' : 'div', {
class: 'html-object-embed',
'data-html-object-embed-label': widgetLabel
}, viewElement);
return toWidget(viewContainer, writer, {
label: widgetLabel
});
};
}
/**
* Creates object view element from the given model element.
*
* @internal
*/ function createObjectView(viewName, modelElement, writer) {
return writer.createRawElement(viewName, null, (domElement, domConverter)=>{
domConverter.setContentOf(domElement, modelElement.getAttribute('htmlContent'));
});
}
/**
* View-to-attribute conversion helper preserving inline element attributes on `$text`.
*
* @returns Returns a conversion callback.
* @internal
*/ function viewToAttributeInlineConverter({ view: viewName, model: attributeKey, allowEmpty }, dataFilter) {
return (dispatcher)=>{
dispatcher.on(`element:${viewName}`, (evt, data, conversionApi)=>{
let viewAttributes = dataFilter.processViewAttributes(data.viewItem, conversionApi);
// Do not apply the attribute if the element itself is already consumed and there are no view attributes to store.
if (!viewAttributes && !conversionApi.consumable.test(data.viewItem, {
name: true
})) {
return;
}
// Otherwise, we might need to convert it to an empty object just to preserve element itself,
// for example `<cite>` => <$text htmlCite="{}">.
viewAttributes = viewAttributes || {};
// Consume the element itself if it wasn't consumed by any other converter.
conversionApi.consumable.consume(data.viewItem, {
name: true
});
// Since we are converting to attribute we need a range on which we will set the attribute.
// If the range is not created yet, we will create it.
if (!data.modelRange) {
data = Object.assign(data, conversionApi.convertChildren(data.viewItem, data.modelCursor));
}
// Convert empty inline element if allowed and has any attributes.
if (allowEmpty && data.modelRange.isCollapsed && Object.keys(viewAttributes).length) {
const modelElement = conversionApi.writer.createElement('htmlEmptyElement');
if (!conversionApi.safeInsert(modelElement, data.modelCursor)) {
return;
}
const parts = conversionApi.getSplitParts(modelElement);
data.modelRange = conversionApi.writer.createRange(data.modelRange.start, conversionApi.writer.createPositionAfter(parts[parts.length - 1]));
conversionApi.updateConversionResult(modelElement, data);
setAttributeOnItem(modelElement, viewAttributes, conversionApi);
return;
}
// Set attribute on each item in range according to the schema.
for (const node of data.modelRange.getItems()){
setAttributeOnItem(node, viewAttributes, conversionApi);
}
}, {
priority: 'low'
});
};
function setAttributeOnItem(node, viewAttributes, conversionApi) {
if (conversionApi.schema.checkAttribute(node, attributeKey)) {
// Node's children are converted recursively, so node can already include model attribute.
// We want to extend it, not replace.
const nodeAttributes = node.getAttribute(attributeKey);
const attributesToAdd = mergeViewElementAttributes(viewAttributes, nodeAttributes || {});
conversionApi.writer.setAttribute(attributeKey, attributesToAdd, node);
}
}
}
/**
* Conversion helper converting an empty inline model element to an HTML element or widget.
*
* @internal
*/ function emptyInlineModelElementToViewConverter({ model: attributeKey, view: viewName }, asWidget) {
return (item, { writer, consumable })=>{
if (!item.hasAttribute(attributeKey)) {
return null;
}
const viewElement = writer.createContainerElement(viewName);
const attributeValue = item.getAttribute(attributeKey);
consumable.consume(item, `attribute:${attributeKey}`);
setViewAttributes(writer, attributeValue, viewElement);
viewElement.getFillerOffset = ()=>null;
return asWidget ? toWidget(viewElement, writer) : viewElement;
};
}
/**
* Attribute-to-view conversion helper applying attributes to view element preserved on `$text`.
*
* @returns Returns a conversion callback.
* @internal
*/ function attributeToViewInlineConverter({ priority, view: viewName }) {
return (attributeValue, conversionApi)=>{
if (!attributeValue) {
return;
}
const { writer } = conversionApi;
const viewElement = writer.createAttributeElement(viewName, null, {
priority
});
setViewAttributes(writer, attributeValue, viewElement);
return viewElement;
};
}
/**
* View-to-model conversion helper preserving allowed attributes on block element.
*
* All matched attributes will be preserved on `html*Attributes` attribute.
*
* @returns Returns a conversion callback.
* @internal
*/ function viewToModelBlockAttributeConverter({ view: viewName }, dataFilter) {
return (dispatcher)=>{
dispatcher.on(`element:${viewName}`, (evt, data, conversionApi)=>{
// Converting an attribute of an element that has not been converted to anything does not make sense
// because there will be nowhere to set that attribute on. At this stage, the element should've already
// been converted. A collapsed range can show up in to-do lists (<input>) or complex widgets (e.g. table).
// (https://github.com/ckeditor/ckeditor5/issues/11000).
if (!data.modelRange || data.modelRange.isCollapsed) {
return;
}
const viewAttributes = dataFilter.processViewAttributes(data.viewItem, conversionApi);
if (!viewAttributes) {
return;
}
conversionApi.writer.setAttribute(getHtmlAttributeName(data.viewItem.name), viewAttributes, data.modelRange);
}, {
priority: 'low'
});
};
}
/**
* Model-to-view conversion helper applying attributes preserved in `html*Attributes` attribute
* for block elements.
*
* @returns Returns a conversion callback.
* @internal
*/ function modelToViewBlockAttributeConverter({ view: viewName, model: modelName }) {
return (dispatcher)=>{
dispatcher.on(`attribute:${getHtmlAttributeName(viewName)}:${modelName}`, (evt, data, conversionApi)=>{
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
const { attributeOldValue, attributeNewValue } = data;
const viewWriter = conversionApi.writer;
const viewElement = conversionApi.mapper.toViewElement(data.item);
updateViewAttributes(viewWriter, attributeOldValue, attributeNewValue, viewElement);
});
};
}
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module html-support/schemadefinitions
*/ /**
* Skipped elements due to HTML deprecation:
* * noframes (not sure if we should provide support for this element. CKE4 is not supporting frameset and frame,
* but it will unpack <frameset><noframes>foobar</noframes></frameset> to <noframes>foobar</noframes>, so there
* may be some content loss. Although using noframes as a standalone element seems invalid)
* * keygen (this one is also empty)
* * applet (support is limited mostly to old IE)
* * basefont (this one is also empty)
* * isindex (basically no support for modern browsers at all)
*
* Skipped elements due to lack empty element support:
* * hr
* * area
* * br
* * command
* * map
* * wbr
* * colgroup -> col
*
* Skipped elements due to complexity:
* * datalist with option elements used as a data source for input[list] element
*
* Skipped elements as they are handled as an object content:
* * track
* * source
* * option
* * param
* * optgroup
*
* Skipped full page HTML elements:
* * body
* * html
* * title
* * head
* * meta
* * link
* * etc...
*
* Skipped hidden elements:
* noscript
*
* When adding elements to this list, update the feature guide listing, too.
*
* @internal
*/ const defaultConfig = {
block: [
// Existing features.
{
model: 'codeBlock',
view: 'pre'
},
{
model: 'paragraph',
view: 'p'
},
{
model: 'blockQuote',
view: 'blockquote'
},
{
model: 'listItem',
view: 'li'
},
{
model: 'pageBreak',
view: 'div'
},
{
model: 'rawHtml',
view: 'div'
},
{
model: 'table',
view: 'table'
},
{
model: 'tableRow',
view: 'tr'
},
{
model: 'tableCell',
view: 'td'
},
{
model: 'tableCell',
view: 'th'
},
{
model: 'tableColumnGroup',
view: 'colgroup'
},
{
model: 'tableColumn',
view: 'col'
},
{
model: 'caption',
view: 'caption'
},
{
model: 'caption',
view: 'figcaption'
},
{
model: 'imageBlock',
view: 'img'
},
{
model: 'imageInline',
view: 'img'
},
{
model: 'horizontalLine',
view: 'hr'
},
// Compatibility features.
{
model: 'htmlP',
view: 'p',
modelSchema: {
inheritAllFrom: '$block'
}
},
{
model: 'htmlBlockquote',
view: 'blockquote',
modelSchema: {
inheritAllFrom: '$container'
}
},
{
model: 'htmlTable',
view: 'table',
modelSchema: {
allowWhere: '$block',
isBlock: true
}
},
{
model: 'htmlTbody',
view: 'tbody',
modelSchema: {
allowIn: 'htmlTable',
isBlock: false
}
},
{
model: 'htmlThead',
view: 'thead',
modelSchema: {
allowIn: 'htmlTable',
isBlock: false
}
},
{
model: 'htmlTfoot',
view: 'tfoot',
modelSchema: {
allowIn: 'htmlTable',
isBlock: false
}
},
{
model: 'htmlCaption',
view: 'caption',
modelSchema: {
allowIn: 'htmlTable',
allowChildren: '$text',
isBlock: false
}
},
{
model: 'htmlColgroup',
view: 'colgroup',
modelSchema: {
allowIn: 'htmlTable',
allowChildren: 'col',
isBlock: false
}
},
{
model: 'htmlCol',
view: 'col',
modelSchema: {
allowIn: 'htmlColgroup',
isBlock: false
}
},
{
model: 'htmlTr',
view: 'tr',
modelSchema: {
allowIn: [
'htmlTable',
'htmlThead',
'htmlTbody'
],
isLimit: true
}
},
// TODO can also include text.
{
model: 'htmlTd',
view: 'td',
modelSchema: {
allowIn: 'htmlTr',
allowContentOf: '$container',
isLimit: true,
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlTh',
view: 'th',
modelSchema: {
allowIn: 'htmlTr',
allowContentOf: '$container',
isLimit: true,
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlFigure',
view: 'figure',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
// TODO can also include other block elements.
{
model: 'htmlFigcaption',
view: 'figcaption',
modelSchema: {
allowIn: 'htmlFigure',
allowChildren: '$text',
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlAddress',
view: 'address',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlAside',
view: 'aside',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlMain',
view: 'main',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlDetails',
view: 'details',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
{
model: 'htmlSummary',
view: 'summary',
modelSchema: {
allowChildren: [
'htmlH1',
'htmlH2',
'htmlH3',
'htmlH4',
'htmlH5',
'htmlH6',
'$text'
],
allowIn: 'htmlDetails',
isBlock: false
}
},
{
model: 'htmlDiv',
view: 'div',
paragraphLikeModel: 'htmlDivParagraph',
modelSchema: {
inheritAllFrom: '$container'
}
},
// TODO can also include text.
{
model: 'htmlFieldset',
view: 'fieldset',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
// TODO can also include h1-h6.
{
model: 'htmlLegend',
view: 'legend',
modelSchema: {
allowIn: 'htmlFieldset',
allowChildren: '$text'
}
},
// TODO can also include text.
{
model: 'htmlHeader',
view: 'header',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlFooter',
view: 'footer',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlForm',
view: 'form',
modelSchema: {
inheritAllFrom: '$container',
isBlock: true
}
},
{
model: 'htmlHgroup',
view: 'hgroup',
modelSchema: {
allowIn: [
'$root',
'$container'
],
allowChildren: [
'paragraph',
'htmlP',
'htmlH1',
'htmlH2',
'htmlH3',
'htmlH4',
'htmlH5',
'htmlH6'
],
isBlock: false
}
},
{
model: 'htmlH1',
view: 'h1',
modelSchema: {
inheritAllFrom: '$block'
}
},
{
model: 'htmlH2',
view: 'h2',
modelSchema: {
inheritAllFrom: '$block'
}
},
{
model: 'htmlH3',
view: 'h3',
modelSchema: {
inheritAllFrom: '$block'
}
},
{
model: 'htmlH4',
view: 'h4',
modelSchema: {
inheritAllFrom: '$block'
}
},
{
model: 'htmlH5',
view: 'h5',
modelSchema: {
inheritAllFrom: '$block'
}
},
{
model: 'htmlH6',
view: 'h6',
modelSchema: {
inheritAllFrom: '$block'
}
},
{
model: '$htmlList',
modelSchema: {
allowWhere: '$container',
allowChildren: [
'$htmlList',
'htmlLi'
],
isBlock: false
}
},
{
model: 'htmlDir',
view: 'dir',
modelSchema: {
inheritAllFrom: '$htmlList'
}
},
{
model: 'htmlMenu',
view: 'menu',
modelSchema: {
inheritAllFrom: '$htmlList'
}
},
{
model: 'htmlUl',
view: 'ul',
modelSchema: {
inheritAllFrom: '$htmlList'
}
},
{
model: 'htmlOl',
view: 'ol',
modelSchema: {
inheritAllFrom: '$htmlList'
}
},
// TODO can also include other block elements.
{
model: 'htmlLi',
view: 'li',
modelSchema: {
allowIn: '$htmlList',
allowChildren: '$text',
isBlock: false
}
},
{
model: 'htmlPre',
view: 'pre',
modelSchema: {
inheritAllFrom: '$block'
}
},
{
model: 'htmlArticle',
view: 'article',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
{
model: 'htmlSection',
view: 'section',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
// TODO can also include text.
{
model: 'htmlNav',
view: 'nav',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
{
model: 'htmlDivDl',
view: 'div',
modelSchema: {
allowChildren: [
'htmlDt',
'htmlDd'
],
allowIn: 'htmlDl'
}
},
{
model: 'htmlDl',
view: 'dl',
modelSchema: {
allowWhere: '$container',
allowChildren: [
'htmlDt',
'htmlDd',
'htmlDivDl'
],
isBlock: false
}
},
{
model: 'htmlDt',
view: 'dt',
modelSchema: {
allowChildren: '$block',
isBlock: false
}
},
{
model: 'htmlDd',
view: 'dd',
modelSchema: {
allowChildren: '$block',
isBlock: false
}
},
{
model: 'htmlCenter',
view: 'center',
modelSchema: {
inheritAllFrom: '$container',
isBlock: false
}
},
{
model: 'htmlHr',
view: 'hr',
isEmpty: true,
modelSchema: {
inheritAllFrom: '$blockObject'
}
}
],
inline: [
// Existing features (attribute set on an existing model element).
{
model: 'htmlLiAttributes',
view: 'li',
appliesToBlock: true,
coupledAttribute: 'listItemId'
},
{
model: 'htmlOlAttributes',
view: 'ol',
appliesToBlock: true,
coupledAttribute: 'listItemId'
},
{
model: 'htmlUlAttributes',
view: 'ul',
appliesToBlock: true,
coupledAttribute: 'listItemId'
},
{
model: 'htmlFigureAttributes',
view: 'figure',
appliesToBlock: 'table'
},
{
model: 'htmlTheadAttributes',
view: 'thead',
appliesToBlock: 'table'
},
{
model: 'htmlTbodyAttributes',
view: 'tbody',
appliesToBlock: 'table'
},
{
model: 'htmlFigureAttributes',
view: 'figure',
appliesToBlock: 'imageBlock'
},
// Compatibility features.
{
model: 'htmlAcronym',
view: 'acronym',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlTt',
view: 'tt',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlFont',
view: 'font',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlTime',
view: 'time',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlVar',
view: 'var',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlBig',
view: 'big',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlSmall',
view: 'small',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlSamp',
view: 'samp',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlQ',
view: 'q',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlOutput',
view: 'output',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlKbd',
view: 'kbd',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlBdi',
view: 'bdi',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlBdo',
view: 'bdo',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlAbbr',
view: 'abbr',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlA',
view: 'a',
priority: 5,
coupledAttribute: 'linkHref',
attributeProperties: {
isFormatting: true
}
},
{
model: 'htmlStrong',
view: 'strong',
coupledAttribute: 'bold',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlB',
view: 'b',
coupledAttribute: 'bold',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlI',
view: 'i',
coupledAttribute: 'italic',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlEm',
view: 'em',
coupledAttribute: 'italic',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlS',
view: 's',
coupledAttribute: 'strikethrough',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
// TODO According to HTML-spec can behave as div-like element, although CKE4 only handles it as an inline element.
{
model: 'htmlDel',
view: 'del',
coupledAttribute: 'strikethrough',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
// TODO According to HTML-spec can behave as div-like element, although CKE4 only handles it as an inline element.
{
model: 'htmlIns',
view: 'ins',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlU',
view: 'u',
coupledAttribute: 'underline',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlSub',
view: 'sub',
coupledAttribute: 'subscript',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlSup',
view: 'sup',
coupledAttribute: 'superscript',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlCode',
view: 'code',
coupledAttribute: 'code',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlMark',
view: 'mark',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlSpan',
view: 'span',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlCite',
view: 'cite',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlLabel',
view: 'label',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
{
model: 'htmlDfn',
view: 'dfn',
attributeProperties: {
copyOnEnter: true,
isFormatting: true
}
},
// Objects.
{
model: 'htmlObject',
view: 'object',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlIframe',
view: 'iframe',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlInput',
view: 'input',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlButton',
view: 'button',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlTextarea',
view: 'textarea',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlSelect',
view: 'select',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlVideo',
view: 'video',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlEmbed',
view: 'embed',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlOembed',
view: 'oembed',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlAudio',
view: 'audio',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlImg',
view: 'img',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlCanvas',
view: 'canvas',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
// TODO it could be probably represented as non-object element, although it has graphical representation,
// so probably makes more sense to keep it as an object.
{
model: 'htmlMeter',
view: 'meter',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
// TODO it could be probably represented as non-object element, although it has graphical representation,
// so probably makes more sense to keep it as an object.
{
model: 'htmlProgress',
view: 'progress',
isObject: true,
modelSchema: {
inheritAllFrom: '$inlineObject'
}
},
{
model: 'htmlScript',
view: 'script',
modelSchema: {
allowWhere: [
'$text',
'$block'
],
isInline: true
}
},
{
model: 'htmlStyle',
view: 'style',
modelSchema: {
allowWhere: [
'$text',
'$block'
],
isInline: true
}
},
{
model: 'htmlCustomElement',
view: '$customElement',
modelSchema: {
allowWhere: [
'$text',
'$block'
],
allowAttributesOf: '$inlineObject',
isInline: true
}
}
]
};
/**
* Holds representation of the extended HTML document type definitions to be used by the
* editor in HTML support.
*
* Data schema is represented by data schema definitions.
*
* To add new definition for block element,
* use {@link module:html-support/dataschema~DataSchema#registerBlockElement} method:
*
* ```ts
* dataSchema.registerBlockElement( {
* view: 'section',
* model: 'my-section',
* modelSchema: {
* inheritAllFrom: '$block'
* }
* } );
* ```
*
* To add new definition for inline element,
* use {@link module:html-support/dataschema~DataSchema#registerInlineElement} method:
*
* ```
* dataSchema.registerInlineElement( {
* view: 'span',
* model: 'my-span',
* attributeProperties: {
* copyOnEnter: true
* }
* } );
* ```
*/ class DataSchema extends Plugin {
/**
* A map of registered data schema definitions.
*/ _definitions = [];
/**
* @inheritDoc
*/ static get pluginName() {
return 'DataSchema';
}
/**
* @inheritDoc
*/ static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/ init() {
for (const definition of defaultConfig.block){
this.registerBlockElement(definition);
}
for (const definition of defaultConfig.inline){
this.registerInlineElement(definition);
}
}
/**
* Add new data schema definition describing block element.
*/ registerBlockElement(definition) {
this._definitions.push({
...definition,
isBlock: true
});
}
/**
* Add new data schema definition describing inline element.
*/ registerInlineElement(definition) {
this._definitions.push({
...definition,
isInline: true
});
}
/**
* Updates schema definition describing block element with new properties.
*
* Creates new scheme if it doesn't exist.
* Array properties are concatenated with original values.
*
* @param definition Definition update.
*/ extendBlockElement(definition) {
this._extendDefinition({
...definition,
isBlock: true
});
}
/**
* Updates schema definition describing inline element with new properties.
*
* Creates new scheme if it doesn't exist.
* Array properties are concatenated with original values.
*
* @param definition Definition update.
*/ extendInlineElement(definition) {
this._extendDefinition({
...definition,
isInline: true
});
}
/**
* Returns all definitions matching the given view name.
*
* @param includeReferences Indicates if this method should also include definitions of referenced models.
*/ getDefinitionsForView(viewName, includeReferences = false) {
const definitions = new Set();
for (const definition of this._getMatchingViewDefinitions(viewName)){
if (includeReferences) {
for (const reference of this._getReferences(definition.model)){
definitions.add(reference);
}
}
definitions.add(definition);
}
return definitions;
}
/**
* Returns definitions matching the given model name.
*/ getDefinitionsForModel(modelName) {
return this._definitions.filter((definition)=>definition.model == modelName);
}
/**
* Returns definitions matching the given view name.
*/ _getMatchingViewDefinitions(viewName) {
return this._definitions.filter((def)=>def.view && testViewName(viewName, def.view));
}
/**
* Resolves all definition references registered for the given data schema definition.
*
* @param modelName Data schema model name.
*/ *_getReferences(modelName) {
const inheritProperties = [
'inheritAllFrom',
'inheritTypesFrom',
'allowWhere',
'allowContentOf',
'allowAttributesOf'
];
const definitions = this._definitions.filter((definition)=>definition.model == modelName);
for (const { modelSchema } of definitions){
if (!modelSchema) {
continue;
}
for (const property of inheritProperties){
for (const referenceName of toArray(modelSchema[property] || [])){
const definitions = this._definitions.filter((definition)=>definition.model == referenceName);
for (const definition of definitions){
if (referenceName !== modelName) {
yield* this._getReferences(definition.model);
yield definition;
}
}
}
}
}
}
/**
* Updates schema definition with new properties.
*
* Creates new scheme if it doesn't exist.
* Array properties are concatenated with original values.
*
* @param definition Definition update.
*/ _extendDefinition(definition) {
const currentDefinitions = Array.from(this._definitions.entries()).filter(([, currentDefinition])=>currentDefinition.model == definition.model);
if (currentDefinitions.length == 0) {
this._definitions.push(definition);
return;
}
for (const [idx, currentDefinition] of currentDefinitions){
this._definitions[idx] = mergeWith({}, currentDefinition, definition, (target, source)=>{
return Array.isArray(target) ? target.concat(source) : undefined;
});
}
}
}
/**
* Test view name against the given pattern.
*/ function testViewName(pattern, viewName) {
if (typeof pattern === 'string') {
return pattern === viewName;
}
if (pattern instanceof RegExp) {
return pattern.test(viewName);
}
return false;
}
/**
* Allows to validate elements and element attributes registered by {@link module:html-support/dataschema~DataSchema}.
*
* To enable registered element in the editor, use {@link module:html-support/datafilter~DataFilter#allowElement} method:
*
* ```ts
* dataFilter.allowElement( 'section' );
* ```
*
* You can also allow or disallow specific element attributes:
*
* ```ts
* // Allow `data-foo` attribute on `section` element.
* dataFilter.allowAttributes( {
* name: 'section',
* attributes: {
* 'data-foo': true
* }
* } );
*
* // Disallow `color` style attribute on 'section' element.
* dataFilter.disallowAttributes( {
* name: 'section',
* styles: {
* color: /[\s\S]+/
* }
* } );
* ```
*
* To apply the information about allowed and disallowed attributes in custom integration plugin,
* use the {@link module:html-support/datafilter~DataFilter#processViewAttributes `processViewAttributes()`} method.
*/ class DataFilter extends Plugin {
/**
* An instance of the {@link module:html-support/dataschema~DataSchema}.
*/ _dataSchema;
/**
* {@link module:engine/view/matcher~Matcher Matcher} instance describing rules upon which
* content attributes should be allowed.
*/ _allowedAttributes;
/**
* {@link module:engine/view/matcher~Matcher Matcher} instance describing rules upon which
* content attributes should be disallowed.
*/ _disallowedAttributes;
/**
* Allowed element definitions by {@link module:html-support/datafilter~DataFilter#allowElement} method.
*/ _allowedElements;
/**
* Disallowed element names by {@link module:html-support/datafilter~DataFilter#disallowElement} method.
*/ _disallowedElements;
/**
* Indicates if {@link module:engine/controller/datacontroller~DataController editor's data controller}
* data has been already initialized.
*/ _dataInitialized;
/**
* Cached map of coupled attributes. Keys are the feature attributes names
* and values are arrays with coupled GHS attributes names.
*/ _coupledAttributes;
constructor(editor){
super(editor);
this._dataSchema = editor.plugins.get('DataSchema');
this._allowedAttributes = new Matcher();
this._disallowedAttributes = new Matcher();
this._allowedElements = new Set();
this._disallowedElements = new Set();
this._dataInitialized = false;
this._coupledAttributes = null;
this._registerElementsAfterInit();
this._registerElementHandlers();
this._registerCoupledAttributesPostFixer();
this._registerAssociatedHtmlAttributesPostFixer();
}
/**
* @inheritDoc
*/ static get pluginName() {
return 'DataFilter';
}
/**
* @inheritDoc
*/ static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/ static get requires() {
return [
DataSchema,
Widget
];
}
/**
* Load a configuration of one or many elements, where their attributes should be allowed.
*
* **Note**: Rules will be applied just before next data pipeline data init or set.
*
* @param config Configuration of elements that should have their attributes accepted in the editor.
*/ loadAllowedConfig(config) {
for (const pattern of config){
// MatcherPattern allows omitting `name` to widen the search of elements.
// Let's keep it consistent and match every element if a `name` has not been provided.
const elementName = pattern.name || /[\s\S]+/;
const rules = splitRules(pattern);
this.allowElement(elemen