UNPKG

@ckeditor/ckeditor5-image

Version:

Image feature for CKEditor 5.

233 lines (232 loc) • 10.6 kB
/** * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ import { first } from 'ckeditor5/src/utils.js'; /** * Returns a function that converts the image view representation: * * ```html * <figure class="image"><img src="..." alt="..."></img></figure> * ``` * * to the model representation: * * ```html * <imageBlock src="..." alt="..."></imageBlock> * ``` * * The entire content of the `<figure>` element except the first `<img>` is being converted as children * of the `<imageBlock>` model element. * * @internal */ export function upcastImageFigure(imageUtils) { const converter = (evt, data, conversionApi) => { // Do not convert if this is not an "image figure". if (!conversionApi.consumable.test(data.viewItem, { name: true, classes: 'image' })) { return; } // Find an image element inside the figure element. const viewImage = imageUtils.findViewImgElement(data.viewItem); // Do not convert if image element is absent or was already converted. if (!viewImage || !conversionApi.consumable.test(viewImage, { name: true })) { return; } // Consume the figure to prevent other converters from processing it again. conversionApi.consumable.consume(data.viewItem, { name: true, classes: 'image' }); // Convert view image to model image. const conversionResult = conversionApi.convertItem(viewImage, data.modelCursor); // Get image element from conversion result. const modelImage = first(conversionResult.modelRange.getItems()); // When image wasn't successfully converted then finish conversion. if (!modelImage) { // Revert consumed figure so other features can convert it. conversionApi.consumable.revert(data.viewItem, { name: true, classes: 'image' }); return; } // Convert rest of the figure element's children as an image children. conversionApi.convertChildren(data.viewItem, modelImage); conversionApi.updateConversionResult(modelImage, data); }; return dispatcher => { dispatcher.on('element:figure', converter); }; } /** * Returns a function that converts the image view representation: * * ```html * <picture><source ... /><source ... />...<img ... /></picture> * ``` * * to the model representation as the `sources` attribute: * * ```html * <image[Block|Inline] ... sources="..."></image[Block|Inline]> * ``` * * @internal */ export function upcastPicture(imageUtils) { const sourceAttributeNames = ['srcset', 'media', 'type', 'sizes']; const converter = (evt, data, conversionApi) => { const pictureViewElement = data.viewItem; // Do not convert <picture> if already consumed. if (!conversionApi.consumable.test(pictureViewElement, { name: true })) { return; } const sources = new Map(); // Collect all <source /> elements attribute values. for (const childSourceElement of pictureViewElement.getChildren()) { if (childSourceElement.is('element', 'source')) { const attributes = {}; for (const name of sourceAttributeNames) { if (childSourceElement.hasAttribute(name)) { // Don't collect <source /> attribute if already consumed somewhere else. if (conversionApi.consumable.test(childSourceElement, { attributes: name })) { attributes[name] = childSourceElement.getAttribute(name); } } } if (Object.keys(attributes).length) { sources.set(childSourceElement, attributes); } } } const imgViewElement = imageUtils.findViewImgElement(pictureViewElement); // Don't convert when a picture has no <img/> inside (it is broken). if (!imgViewElement) { return; } let modelImage = data.modelCursor.parent; // - In case of an inline image (cursor parent in a <paragraph>), the <img/> must be converted right away // because no converter handled it yet and otherwise there would be no model element to set the sources attribute on. // - In case of a block image, the <figure class="image"> converter (in ImageBlockEditing) converts the // <img/> right away on its own and the modelCursor is already inside an imageBlock and there's nothing special // to do here. if (!modelImage.is('element', 'imageBlock')) { const conversionResult = conversionApi.convertItem(imgViewElement, data.modelCursor); // Set image range as conversion result. data.modelRange = conversionResult.modelRange; // Continue conversion where image conversion ends. data.modelCursor = conversionResult.modelCursor; modelImage = first(conversionResult.modelRange.getItems()); } conversionApi.consumable.consume(pictureViewElement, { name: true }); // Consume only these <source/> attributes that were actually collected and will be passed on // to the image model element. for (const [sourceElement, attributes] of sources) { conversionApi.consumable.consume(sourceElement, { attributes: Object.keys(attributes) }); } if (sources.size) { conversionApi.writer.setAttribute('sources', Array.from(sources.values()), modelImage); } // Convert rest of the <picture> children as an image children. Other converters may want to consume them. conversionApi.convertChildren(pictureViewElement, modelImage); }; return dispatcher => { dispatcher.on('element:picture', converter); }; } /** * Converter used to convert the `srcset` model image attribute to the `srcset` and `sizes` attributes in the view. * * @internal * @param imageType The type of the image. */ export function downcastSrcsetAttribute(imageUtils, imageType) { const converter = (evt, data, conversionApi) => { if (!conversionApi.consumable.consume(data.item, evt.name)) { return; } const writer = conversionApi.writer; const element = conversionApi.mapper.toViewElement(data.item); const img = imageUtils.findViewImgElement(element); if (data.attributeNewValue === null) { writer.removeAttribute('srcset', img); writer.removeAttribute('sizes', img); } else { if (data.attributeNewValue) { writer.setAttribute('srcset', data.attributeNewValue, img); // Always outputting `100vw`. See https://github.com/ckeditor/ckeditor5-image/issues/2. writer.setAttribute('sizes', '100vw', img); } } }; return dispatcher => { dispatcher.on(`attribute:srcset:${imageType}`, converter); }; } /** * Converts the `source` model attribute to the `<picture><source /><source />...<img /></picture>` * view structure. * * @internal */ export function downcastSourcesAttribute(imageUtils) { const converter = (evt, data, conversionApi) => { if (!conversionApi.consumable.consume(data.item, evt.name)) { return; } const viewWriter = conversionApi.writer; const element = conversionApi.mapper.toViewElement(data.item); const imgElement = imageUtils.findViewImgElement(element); const attributeNewValue = data.attributeNewValue; if (attributeNewValue && attributeNewValue.length) { // Make sure <picture> does not break attribute elements, for instance <a> in linked images. const pictureElement = viewWriter.createContainerElement('picture', null, attributeNewValue.map(sourceAttributes => { return viewWriter.createEmptyElement('source', sourceAttributes); })); // Collect all wrapping attribute elements. const attributeElements = []; let viewElement = imgElement.parent; while (viewElement && viewElement.is('attributeElement')) { const parentElement = viewElement.parent; viewWriter.unwrap(viewWriter.createRangeOn(imgElement), viewElement); attributeElements.unshift(viewElement); viewElement = parentElement; } // Insert the picture and move img into it. viewWriter.insert(viewWriter.createPositionBefore(imgElement), pictureElement); viewWriter.move(viewWriter.createRangeOn(imgElement), viewWriter.createPositionAt(pictureElement, 'end')); // Apply collected attribute elements over the new picture element. for (const attributeElement of attributeElements) { viewWriter.wrap(viewWriter.createRangeOn(pictureElement), attributeElement); } } // Both setting "sources" to an empty array and removing the attribute should unwrap the <img />. // Unwrap once if the latter followed the former, though. else if (imgElement.parent.is('element', 'picture')) { const pictureElement = imgElement.parent; viewWriter.move(viewWriter.createRangeOn(imgElement), viewWriter.createPositionBefore(pictureElement)); viewWriter.remove(pictureElement); } }; return dispatcher => { dispatcher.on('attribute:sources:imageBlock', converter); dispatcher.on('attribute:sources:imageInline', converter); }; } /** * Converter used to convert a given image attribute from the model to the view. * * @internal * @param imageType The type of the image. * @param attributeKey The name of the attribute to convert. */ export function downcastImageAttribute(imageUtils, imageType, attributeKey) { const converter = (evt, data, conversionApi) => { if (!conversionApi.consumable.consume(data.item, evt.name)) { return; } const viewWriter = conversionApi.writer; const element = conversionApi.mapper.toViewElement(data.item); const img = imageUtils.findViewImgElement(element); viewWriter.setAttribute(data.attributeKey, data.attributeNewValue || '', img); }; return dispatcher => { dispatcher.on(`attribute:${attributeKey}:${imageType}`, converter); }; }