UNPKG

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