UNPKG

@ckeditor/ckeditor5-html-support

Version:

HTML Support feature for CKEditor 5.

1,662 lines (1,657 loc) • 169 kB
/** * @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