@ckeditor/ckeditor5-image
Version:
Image feature for CKEditor 5.
307 lines (306 loc) • 12.6 kB
JavaScript
/**
* @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 { Plugin } from 'ckeditor5/src/core.js';
import { findOptimalInsertionRange, isWidget, toWidget } from 'ckeditor5/src/widget.js';
import { determineImageTypeForInsertionAtSelection } from './image/utils.js';
import { DomEmitterMixin, global } from 'ckeditor5/src/utils.js';
const IMAGE_WIDGETS_CLASSES_MATCH_REGEXP = /^(image|image-inline)$/;
/**
* A set of helpers related to images.
*/
export default class ImageUtils extends Plugin {
constructor() {
super(...arguments);
/**
* DOM Emitter.
*/
this._domEmitter = new (DomEmitterMixin())();
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'ImageUtils';
}
/**
* Checks if the provided model element is an `image` or `imageInline`.
*/
isImage(modelElement) {
return this.isInlineImage(modelElement) || this.isBlockImage(modelElement);
}
/**
* Checks if the provided view element represents an inline image.
*
* Also, see {@link module:image/imageutils~ImageUtils#isImageWidget}.
*/
isInlineImageView(element) {
return !!element && element.is('element', 'img');
}
/**
* Checks if the provided view element represents a block image.
*
* Also, see {@link module:image/imageutils~ImageUtils#isImageWidget}.
*/
isBlockImageView(element) {
return !!element && element.is('element', 'figure') && element.hasClass('image');
}
/**
* Handles inserting single file. This method unifies image insertion using {@link module:widget/utils~findOptimalInsertionRange}
* method.
*
* ```ts
* const imageUtils = editor.plugins.get( 'ImageUtils' );
*
* imageUtils.insertImage( { src: 'path/to/image.jpg' } );
* ```
*
* @param attributes Attributes of the inserted image.
* This method filters out the attributes which are disallowed by the {@link module:engine/model/schema~Schema}.
* @param selectable Place to insert the image. If not specified,
* the {@link module:widget/utils~findOptimalInsertionRange} logic will be applied for the block images
* and `model.document.selection` for the inline images.
*
* **Note**: If `selectable` is passed, this helper will not be able to set selection attributes (such as `linkHref`)
* and apply them to the new image. In this case, make sure all selection attributes are passed in `attributes`.
*
* @param imageType Image type of inserted image. If not specified,
* it will be determined automatically depending of editor config or place of the insertion.
* @param options.setImageSizes Specifies whether the image `width` and `height` attributes should be set automatically.
* The default is `true`.
* @return The inserted model image element.
*/
insertImage(attributes = {}, selectable = null, imageType = null, options = {}) {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
const determinedImageType = determineImageTypeForInsertion(editor, selectable || selection, imageType);
// Mix declarative attributes with selection attributes because the new image should "inherit"
// the latter for best UX. For instance, inline images inserted into existing links
// should not split them. To do that, they need to have "linkHref" inherited from the selection.
attributes = {
...Object.fromEntries(selection.getAttributes()),
...attributes
};
for (const attributeName in attributes) {
if (!model.schema.checkAttribute(determinedImageType, attributeName)) {
delete attributes[attributeName];
}
}
return model.change(writer => {
const { setImageSizes = true } = options;
const imageElement = writer.createElement(determinedImageType, attributes);
model.insertObject(imageElement, selectable, null, {
setSelection: 'on',
// If we want to insert a block image (for whatever reason) then we don't want to split text blocks.
// This applies only when we don't have the selectable specified (i.e., we insert multiple block images at once).
findOptimalPosition: !selectable && determinedImageType != 'imageInline' ? 'auto' : undefined
});
// Inserting an image might've failed due to schema regulations.
if (imageElement.parent) {
if (setImageSizes) {
this.setImageNaturalSizeAttributes(imageElement);
}
return imageElement;
}
return null;
});
}
/**
* Reads original image sizes and sets them as `width` and `height`.
*
* The `src` attribute may not be available if the user is using an upload adapter. In such a case,
* this method is called again after the upload process is complete and the `src` attribute is available.
*/
setImageNaturalSizeAttributes(imageElement) {
const src = imageElement.getAttribute('src');
if (!src) {
return;
}
if (imageElement.getAttribute('width') || imageElement.getAttribute('height')) {
return;
}
this.editor.model.change(writer => {
const img = new global.window.Image();
this._domEmitter.listenTo(img, 'load', () => {
if (!imageElement.getAttribute('width') && !imageElement.getAttribute('height')) {
// We use writer.batch to be able to undo (in a single step) width and height setting
// along with any change that triggered this action (e.g. image resize or image style change).
this.editor.model.enqueueChange(writer.batch, writer => {
writer.setAttribute('width', img.naturalWidth, imageElement);
writer.setAttribute('height', img.naturalHeight, imageElement);
});
}
this._domEmitter.stopListening(img, 'load');
});
img.src = src;
});
}
/**
* Returns an image widget editing view element if one is selected or is among the selection's ancestors.
*/
getClosestSelectedImageWidget(selection) {
const selectionPosition = selection.getFirstPosition();
if (!selectionPosition) {
return null;
}
const viewElement = selection.getSelectedElement();
if (viewElement && this.isImageWidget(viewElement)) {
return viewElement;
}
let parent = selectionPosition.parent;
while (parent) {
if (parent.is('element') && this.isImageWidget(parent)) {
return parent;
}
parent = parent.parent;
}
return null;
}
/**
* Returns a image model element if one is selected or is among the selection's ancestors.
*/
getClosestSelectedImageElement(selection) {
const selectedElement = selection.getSelectedElement();
return this.isImage(selectedElement) ? selectedElement : selection.getFirstPosition().findAncestor('imageBlock');
}
/**
* Returns an image widget editing view based on the passed image view.
*/
getImageWidgetFromImageView(imageView) {
return imageView.findAncestor({ classes: IMAGE_WIDGETS_CLASSES_MATCH_REGEXP });
}
/**
* Checks if image can be inserted at current model selection.
*
* @internal
*/
isImageAllowed() {
const model = this.editor.model;
const selection = model.document.selection;
return isImageAllowedInParent(this.editor, selection) && isNotInsideImage(selection);
}
/**
* Converts a given {@link module:engine/view/element~Element} to an image widget:
* * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the image widget
* element.
* * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
*
* @param writer An instance of the view writer.
* @param label The element's label. It will be concatenated with the image `alt` attribute if one is present.
*/
toImageWidget(viewElement, writer, label) {
writer.setCustomProperty('image', true, viewElement);
const labelCreator = () => {
const imgElement = this.findViewImgElement(viewElement);
const altText = imgElement.getAttribute('alt');
return altText ? `${altText} ${label}` : label;
};
return toWidget(viewElement, writer, { label: labelCreator });
}
/**
* Checks if a given view element is an image widget.
*/
isImageWidget(viewElement) {
return !!viewElement.getCustomProperty('image') && isWidget(viewElement);
}
/**
* Checks if the provided model element is an `image`.
*/
isBlockImage(modelElement) {
return !!modelElement && modelElement.is('element', 'imageBlock');
}
/**
* Checks if the provided model element is an `imageInline`.
*/
isInlineImage(modelElement) {
return !!modelElement && modelElement.is('element', 'imageInline');
}
/**
* Get the view `<img>` from another view element, e.g. a widget (`<figure class="image">`), a link (`<a>`).
*
* The `<img>` can be located deep in other elements, so this helper performs a deep tree search.
*/
findViewImgElement(figureView) {
if (this.isInlineImageView(figureView)) {
return figureView;
}
const editingView = this.editor.editing.view;
for (const { item } of editingView.createRangeIn(figureView)) {
if (this.isInlineImageView(item)) {
return item;
}
}
}
/**
* @inheritDoc
*/
destroy() {
this._domEmitter.stopListening();
return super.destroy();
}
}
/**
* Checks if image is allowed by schema in optimal insertion parent.
*/
function isImageAllowedInParent(editor, selection) {
const imageType = determineImageTypeForInsertion(editor, selection, null);
if (imageType == 'imageBlock') {
const parent = getInsertImageParent(selection, editor.model);
if (editor.model.schema.checkChild(parent, 'imageBlock')) {
return true;
}
}
else if (editor.model.schema.checkChild(selection.focus, 'imageInline')) {
return true;
}
return false;
}
/**
* Checks if selection is not placed inside an image (e.g. its caption).
*/
function isNotInsideImage(selection) {
return [...selection.focus.getAncestors()].every(ancestor => !ancestor.is('element', 'imageBlock'));
}
/**
* Returns a node that will be used to insert image with `model.insertContent`.
*/
function getInsertImageParent(selection, model) {
const insertionRange = findOptimalInsertionRange(selection, model);
const parent = insertionRange.start.parent;
if (parent.isEmpty && !parent.is('element', '$root')) {
return parent.parent;
}
return parent;
}
/**
* Determine image element type name depending on editor config or place of insertion.
*
* @param imageType Image element type name. Used to force return of provided element name,
* but only if there is proper plugin enabled.
*/
function determineImageTypeForInsertion(editor, selectable, imageType) {
const schema = editor.model.schema;
const configImageInsertType = editor.config.get('image.insert.type');
if (!editor.plugins.has('ImageBlockEditing')) {
return 'imageInline';
}
if (!editor.plugins.has('ImageInlineEditing')) {
return 'imageBlock';
}
if (imageType) {
return imageType;
}
if (configImageInsertType === 'inline') {
return 'imageInline';
}
if (configImageInsertType !== 'auto') {
return 'imageBlock';
}
// Try to replace the selected widget (e.g. another image).
if (selectable.is('selection')) {
return determineImageTypeForInsertionAtSelection(schema, selectable);
}
return schema.checkChild(selectable, 'imageInline') ? 'imageInline' : 'imageBlock';
}