@ckeditor/ckeditor5-html-support
Version:
HTML Support feature for CKEditor 5.
191 lines (190 loc) • 8.93 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 { toWidget } from 'ckeditor5/src/widget.js';
import { setViewAttributes, mergeViewElementAttributes, updateViewAttributes, getHtmlAttributeName } from './utils.js';
/**
* View-to-model conversion helper for object elements.
*
* Preserves object element content in `htmlContent` attribute.
*
* @returns Returns a conversion callback.
* @internal
*/
export 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
*/
export 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
*/
export 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
*/
export 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
*/
export 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
*/
export 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
*/
export 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
*/
export 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);
});
};
}