UNPKG

12.6 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5import { Plugin } from 'ckeditor5/src/core.js';
6import { findOptimalInsertionRange, isWidget, toWidget } from 'ckeditor5/src/widget.js';
7import { determineImageTypeForInsertionAtSelection } from './image/utils.js';
8import { DomEmitterMixin, global } from 'ckeditor5/src/utils.js';
9const IMAGE_WIDGETS_CLASSES_MATCH_REGEXP = /^(image|image-inline)$/;
10/**
11 * A set of helpers related to images.
12 */
13export default class ImageUtils extends Plugin {
14 constructor() {
15 super(...arguments);
16 /**
17 * DOM Emitter.
18 */
19 this._domEmitter = new (DomEmitterMixin())();
20 }
21 /**
22 * @inheritDoc
23 */
24 static get pluginName() {
25 return 'ImageUtils';
26 }
27 /**
28 * Checks if the provided model element is an `image` or `imageInline`.
29 */
30 isImage(modelElement) {
31 return this.isInlineImage(modelElement) || this.isBlockImage(modelElement);
32 }
33 /**
34 * Checks if the provided view element represents an inline image.
35 *
36 * Also, see {@link module:image/imageutils~ImageUtils#isImageWidget}.
37 */
38 isInlineImageView(element) {
39 return !!element && element.is('element', 'img');
40 }
41 /**
42 * Checks if the provided view element represents a block image.
43 *
44 * Also, see {@link module:image/imageutils~ImageUtils#isImageWidget}.
45 */
46 isBlockImageView(element) {
47 return !!element && element.is('element', 'figure') && element.hasClass('image');
48 }
49 /**
50 * Handles inserting single file. This method unifies image insertion using {@link module:widget/utils~findOptimalInsertionRange}
51 * method.
52 *
53 * ```ts
54 * const imageUtils = editor.plugins.get( 'ImageUtils' );
55 *
56 * imageUtils.insertImage( { src: 'path/to/image.jpg' } );
57 * ```
58 *
59 * @param attributes Attributes of the inserted image.
60 * This method filters out the attributes which are disallowed by the {@link module:engine/model/schema~Schema}.
61 * @param selectable Place to insert the image. If not specified,
62 * the {@link module:widget/utils~findOptimalInsertionRange} logic will be applied for the block images
63 * and `model.document.selection` for the inline images.
64 *
65 * **Note**: If `selectable` is passed, this helper will not be able to set selection attributes (such as `linkHref`)
66 * and apply them to the new image. In this case, make sure all selection attributes are passed in `attributes`.
67 *
68 * @param imageType Image type of inserted image. If not specified,
69 * it will be determined automatically depending of editor config or place of the insertion.
70 * @param options.setImageSizes Specifies whether the image `width` and `height` attributes should be set automatically.
71 * The default is `true`.
72 * @return The inserted model image element.
73 */
74 insertImage(attributes = {}, selectable = null, imageType = null, options = {}) {
75 const editor = this.editor;
76 const model = editor.model;
77 const selection = model.document.selection;
78 const determinedImageType = determineImageTypeForInsertion(editor, selectable || selection, imageType);
79 // Mix declarative attributes with selection attributes because the new image should "inherit"
80 // the latter for best UX. For instance, inline images inserted into existing links
81 // should not split them. To do that, they need to have "linkHref" inherited from the selection.
82 attributes = {
83 ...Object.fromEntries(selection.getAttributes()),
84 ...attributes
85 };
86 for (const attributeName in attributes) {
87 if (!model.schema.checkAttribute(determinedImageType, attributeName)) {
88 delete attributes[attributeName];
89 }
90 }
91 return model.change(writer => {
92 const { setImageSizes = true } = options;
93 const imageElement = writer.createElement(determinedImageType, attributes);
94 model.insertObject(imageElement, selectable, null, {
95 setSelection: 'on',
96 // If we want to insert a block image (for whatever reason) then we don't want to split text blocks.
97 // This applies only when we don't have the selectable specified (i.e., we insert multiple block images at once).
98 findOptimalPosition: !selectable && determinedImageType != 'imageInline' ? 'auto' : undefined
99 });
100 // Inserting an image might've failed due to schema regulations.
101 if (imageElement.parent) {
102 if (setImageSizes) {
103 this.setImageNaturalSizeAttributes(imageElement);
104 }
105 return imageElement;
106 }
107 return null;
108 });
109 }
110 /**
111 * Reads original image sizes and sets them as `width` and `height`.
112 *
113 * The `src` attribute may not be available if the user is using an upload adapter. In such a case,
114 * this method is called again after the upload process is complete and the `src` attribute is available.
115 */
116 setImageNaturalSizeAttributes(imageElement) {
117 const src = imageElement.getAttribute('src');
118 if (!src) {
119 return;
120 }
121 if (imageElement.getAttribute('width') || imageElement.getAttribute('height')) {
122 return;
123 }
124 this.editor.model.change(writer => {
125 const img = new global.window.Image();
126 this._domEmitter.listenTo(img, 'load', () => {
127 if (!imageElement.getAttribute('width') && !imageElement.getAttribute('height')) {
128 // We use writer.batch to be able to undo (in a single step) width and height setting
129 // along with any change that triggered this action (e.g. image resize or image style change).
130 this.editor.model.enqueueChange(writer.batch, writer => {
131 writer.setAttribute('width', img.naturalWidth, imageElement);
132 writer.setAttribute('height', img.naturalHeight, imageElement);
133 });
134 }
135 this._domEmitter.stopListening(img, 'load');
136 });
137 img.src = src;
138 });
139 }
140 /**
141 * Returns an image widget editing view element if one is selected or is among the selection's ancestors.
142 */
143 getClosestSelectedImageWidget(selection) {
144 const selectionPosition = selection.getFirstPosition();
145 if (!selectionPosition) {
146 return null;
147 }
148 const viewElement = selection.getSelectedElement();
149 if (viewElement && this.isImageWidget(viewElement)) {
150 return viewElement;
151 }
152 let parent = selectionPosition.parent;
153 while (parent) {
154 if (parent.is('element') && this.isImageWidget(parent)) {
155 return parent;
156 }
157 parent = parent.parent;
158 }
159 return null;
160 }
161 /**
162 * Returns a image model element if one is selected or is among the selection's ancestors.
163 */
164 getClosestSelectedImageElement(selection) {
165 const selectedElement = selection.getSelectedElement();
166 return this.isImage(selectedElement) ? selectedElement : selection.getFirstPosition().findAncestor('imageBlock');
167 }
168 /**
169 * Returns an image widget editing view based on the passed image view.
170 */
171 getImageWidgetFromImageView(imageView) {
172 return imageView.findAncestor({ classes: IMAGE_WIDGETS_CLASSES_MATCH_REGEXP });
173 }
174 /**
175 * Checks if image can be inserted at current model selection.
176 *
177 * @internal
178 */
179 isImageAllowed() {
180 const model = this.editor.model;
181 const selection = model.document.selection;
182 return isImageAllowedInParent(this.editor, selection) && isNotInsideImage(selection);
183 }
184 /**
185 * Converts a given {@link module:engine/view/element~Element} to an image widget:
186 * * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the image widget
187 * element.
188 * * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
189 *
190 * @param writer An instance of the view writer.
191 * @param label The element's label. It will be concatenated with the image `alt` attribute if one is present.
192 */
193 toImageWidget(viewElement, writer, label) {
194 writer.setCustomProperty('image', true, viewElement);
195 const labelCreator = () => {
196 const imgElement = this.findViewImgElement(viewElement);
197 const altText = imgElement.getAttribute('alt');
198 return altText ? `${altText} ${label}` : label;
199 };
200 return toWidget(viewElement, writer, { label: labelCreator });
201 }
202 /**
203 * Checks if a given view element is an image widget.
204 */
205 isImageWidget(viewElement) {
206 return !!viewElement.getCustomProperty('image') && isWidget(viewElement);
207 }
208 /**
209 * Checks if the provided model element is an `image`.
210 */
211 isBlockImage(modelElement) {
212 return !!modelElement && modelElement.is('element', 'imageBlock');
213 }
214 /**
215 * Checks if the provided model element is an `imageInline`.
216 */
217 isInlineImage(modelElement) {
218 return !!modelElement && modelElement.is('element', 'imageInline');
219 }
220 /**
221 * Get the view `<img>` from another view element, e.g. a widget (`<figure class="image">`), a link (`<a>`).
222 *
223 * The `<img>` can be located deep in other elements, so this helper performs a deep tree search.
224 */
225 findViewImgElement(figureView) {
226 if (this.isInlineImageView(figureView)) {
227 return figureView;
228 }
229 const editingView = this.editor.editing.view;
230 for (const { item } of editingView.createRangeIn(figureView)) {
231 if (this.isInlineImageView(item)) {
232 return item;
233 }
234 }
235 }
236 /**
237 * @inheritDoc
238 */
239 destroy() {
240 this._domEmitter.stopListening();
241 return super.destroy();
242 }
243}
244/**
245 * Checks if image is allowed by schema in optimal insertion parent.
246 */
247function isImageAllowedInParent(editor, selection) {
248 const imageType = determineImageTypeForInsertion(editor, selection, null);
249 if (imageType == 'imageBlock') {
250 const parent = getInsertImageParent(selection, editor.model);
251 if (editor.model.schema.checkChild(parent, 'imageBlock')) {
252 return true;
253 }
254 }
255 else if (editor.model.schema.checkChild(selection.focus, 'imageInline')) {
256 return true;
257 }
258 return false;
259}
260/**
261 * Checks if selection is not placed inside an image (e.g. its caption).
262 */
263function isNotInsideImage(selection) {
264 return [...selection.focus.getAncestors()].every(ancestor => !ancestor.is('element', 'imageBlock'));
265}
266/**
267 * Returns a node that will be used to insert image with `model.insertContent`.
268 */
269function getInsertImageParent(selection, model) {
270 const insertionRange = findOptimalInsertionRange(selection, model);
271 const parent = insertionRange.start.parent;
272 if (parent.isEmpty && !parent.is('element', '$root')) {
273 return parent.parent;
274 }
275 return parent;
276}
277/**
278 * Determine image element type name depending on editor config or place of insertion.
279 *
280 * @param imageType Image element type name. Used to force return of provided element name,
281 * but only if there is proper plugin enabled.
282 */
283function determineImageTypeForInsertion(editor, selectable, imageType) {
284 const schema = editor.model.schema;
285 const configImageInsertType = editor.config.get('image.insert.type');
286 if (!editor.plugins.has('ImageBlockEditing')) {
287 return 'imageInline';
288 }
289 if (!editor.plugins.has('ImageInlineEditing')) {
290 return 'imageBlock';
291 }
292 if (imageType) {
293 return imageType;
294 }
295 if (configImageInsertType === 'inline') {
296 return 'imageInline';
297 }
298 if (configImageInsertType !== 'auto') {
299 return 'imageBlock';
300 }
301 // Try to replace the selected widget (e.g. another image).
302 if (selectable.is('selection')) {
303 return determineImageTypeForInsertionAtSelection(schema, selectable);
304 }
305 return schema.checkChild(selectable, 'imageInline') ? 'imageInline' : 'imageBlock';
306}