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 | */
|
5 | import { Plugin } from 'ckeditor5/src/core.js';
|
6 | import { findOptimalInsertionRange, isWidget, toWidget } from 'ckeditor5/src/widget.js';
|
7 | import { determineImageTypeForInsertionAtSelection } from './image/utils.js';
|
8 | import { DomEmitterMixin, global } from 'ckeditor5/src/utils.js';
|
9 | const IMAGE_WIDGETS_CLASSES_MATCH_REGEXP = /^(image|image-inline)$/;
|
10 | /**
|
11 | * A set of helpers related to images.
|
12 | */
|
13 | export 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 | */
|
247 | function 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 | */
|
263 | function 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 | */
|
269 | function 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 | */
|
283 | function 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 | }
|