UNPKG

@ckeditor/ckeditor5-image

Version:

Image feature for CKEditor 5.

1,225 lines (1,212 loc) • 227 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 { Plugin, Command, icons } from '@ckeditor/ckeditor5-core/dist/index.js'; import { Clipboard, ClipboardPipeline } from '@ckeditor/ckeditor5-clipboard/dist/index.js'; import { LivePosition, LiveRange, Observer, UpcastWriter, enablePlaceholder, Element } from '@ckeditor/ckeditor5-engine/dist/index.js'; import { Undo } from '@ckeditor/ckeditor5-undo/dist/index.js'; import { Delete } from '@ckeditor/ckeditor5-typing/dist/index.js'; import { first, global, DomEmitterMixin, FocusTracker, KeystrokeHandler, toArray, logWarning, env, CKEditorError, Collection, Rect } from '@ckeditor/ckeditor5-utils/dist/index.js'; import { toWidget, isWidget, findOptimalInsertionRange, Widget, toWidgetEditable, WidgetResize, calculateResizeHostAncestorWidth, WidgetToolbarRepository } from '@ckeditor/ckeditor5-widget/dist/index.js'; import { View, submitHandler, ButtonView, LabeledFieldView, createLabeledInputText, ViewCollection, FocusCycler, BalloonPanelView, ContextualBalloon, CssTransitionDisablerMixin, clickOutsideHandler, CollapsibleView, SplitButtonView, createDropdown, MenuBarMenuListItemFileDialogButtonView, FileDialogButtonView, Notification, DropdownButtonView, ViewModel, addListToDropdown, createLabeledInputNumber, addToolbarToDropdown } from '@ckeditor/ckeditor5-ui/dist/index.js'; import { FileRepository } from '@ckeditor/ckeditor5-upload/dist/index.js'; import { map, isObject, identity } from 'lodash-es'; /** * Creates a view element representing the inline image. * * ```html * <span class="image-inline"><img></img></span> * ``` * * Note that `alt` and `src` attributes are converted separately, so they are not included. * * @internal */ function createInlineImageViewElement(writer) { return writer.createContainerElement('span', { class: 'image-inline' }, writer.createEmptyElement('img')); } /** * Creates a view element representing the block image. * * ```html * <figure class="image"><img></img></figure> * ``` * * Note that `alt` and `src` attributes are converted separately, so they are not included. * * @internal */ function createBlockImageViewElement(writer) { return writer.createContainerElement('figure', { class: 'image' }, [ writer.createEmptyElement('img'), writer.createSlot('children') ]); } /** * A function returning a `MatcherPattern` for a particular type of View images. * * @internal * @param matchImageType The type of created image. */ function getImgViewElementMatcher(editor, matchImageType) { const imageUtils = editor.plugins.get('ImageUtils'); const areBothImagePluginsLoaded = editor.plugins.has('ImageInlineEditing') && editor.plugins.has('ImageBlockEditing'); return (element)=>{ // Check if the matched view element is an <img>. if (!imageUtils.isInlineImageView(element)) { return null; } // If just one of the plugins is loaded (block or inline), it will match all kinds of images. if (!areBothImagePluginsLoaded) { return getPositiveMatchPattern(element); } // The <img> can be standalone, wrapped in <figure>...</figure> (ImageBlock plugin) or // wrapped in <figure><a>...</a></figure> (LinkImage plugin). const imageType = element.getStyle('display') == 'block' || element.findAncestor(imageUtils.isBlockImageView) ? 'imageBlock' : 'imageInline'; if (imageType !== matchImageType) { return null; } return getPositiveMatchPattern(element); }; function getPositiveMatchPattern(element) { const pattern = { name: true }; // This will trigger src consumption (See https://github.com/ckeditor/ckeditor5/issues/11530). if (element.hasAttribute('src')) { pattern.attributes = [ 'src' ]; } return pattern; } } /** * Considering the current model selection, it returns the name of the model image element * (`'imageBlock'` or `'imageInline'`) that will make most sense from the UX perspective if a new * image was inserted (also: uploaded, dropped, pasted) at that selection. * * The assumption is that inserting images into empty blocks or on other block widgets should * produce block images. Inline images should be inserted in other cases, e.g. in paragraphs * that already contain some text. * * @internal */ function determineImageTypeForInsertionAtSelection(schema, selection) { const firstBlock = first(selection.getSelectedBlocks()); // Insert a block image if the selection is not in/on block elements or it's on a block widget. if (!firstBlock || schema.isObject(firstBlock)) { return 'imageBlock'; } // A block image should also be inserted into an empty block element // (that is not an empty list item so the list won't get split). if (firstBlock.isEmpty && firstBlock.name != 'listItem') { return 'imageBlock'; } // Otherwise insert an inline image. return 'imageInline'; } /** * Returns parsed value of the size, but only if it contains unit: px. */ function getSizeValueIfInPx(size) { if (size && size.endsWith('px')) { return parseInt(size); } return null; } /** * Returns true if both styles (width and height) are set. * * If both image styles: width & height are set, they will override the image width & height attributes in the * browser. In this case, the image looks the same as if these styles were applied to attributes instead of styles. * That's why we can upcast these styles to width & height attributes instead of resizedWidth and resizedHeight. */ function widthAndHeightStylesAreBothSet(viewElement) { const widthStyle = getSizeValueIfInPx(viewElement.getStyle('width')); const heightStyle = getSizeValueIfInPx(viewElement.getStyle('height')); return !!(widthStyle && heightStyle); } const IMAGE_WIDGETS_CLASSES_MATCH_REGEXP = /^(image|image-inline)$/; class ImageUtils extends Plugin { /** * @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(); } constructor(){ super(...arguments); /** * DOM Emitter. */ this._domEmitter = new (DomEmitterMixin())(); } } /** * 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'; } // Implements the pattern: http(s)://(www.)example.com/path/to/resource.ext?query=params&maybe=too. const IMAGE_URL_REGEXP = new RegExp(String(/^(http(s)?:\/\/)?[\w-]+\.[\w.~:/[\]@!$&'()*+,;=%-]+/.source + /\.(jpg|jpeg|png|gif|ico|webp|JPG|JPEG|PNG|GIF|ICO|WEBP)/.source + /(\?[\w.~:/[\]@!$&'()*+,;=%-]*)?/.source + /(#[\w.~:/[\]@!$&'()*+,;=%-]*)?$/.source)); class AutoImage extends Plugin { /** * @inheritDoc */ static get requires() { return [ Clipboard, ImageUtils, Undo, Delete ]; } /** * @inheritDoc */ static get pluginName() { return 'AutoImage'; } /** * @inheritDoc */ init() { const editor = this.editor; const modelDocument = editor.model.document; const clipboardPipeline = editor.plugins.get('ClipboardPipeline'); // We need to listen on `Clipboard#inputTransformation` because we need to save positions of selection. // After pasting, the content between those positions will be checked for a URL that could be transformed // into an image. this.listenTo(clipboardPipeline, 'inputTransformation', ()=>{ const firstRange = modelDocument.selection.getFirstRange(); const leftLivePosition = LivePosition.fromPosition(firstRange.start); leftLivePosition.stickiness = 'toPrevious'; const rightLivePosition = LivePosition.fromPosition(firstRange.end); rightLivePosition.stickiness = 'toNext'; modelDocument.once('change:data', ()=>{ this._embedImageBetweenPositions(leftLivePosition, rightLivePosition); leftLivePosition.detach(); rightLivePosition.detach(); }, { priority: 'high' }); }); editor.commands.get('undo').on('execute', ()=>{ if (this._timeoutId) { global.window.clearTimeout(this._timeoutId); this._positionToInsert.detach(); this._timeoutId = null; this._positionToInsert = null; } }, { priority: 'high' }); } /** * Analyzes the part of the document between provided positions in search for a URL representing an image. * When the URL is found, it is automatically converted into an image. * * @param leftPosition Left position of the selection. * @param rightPosition Right position of the selection. */ _embedImageBetweenPositions(leftPosition, rightPosition) { const editor = this.editor; // TODO: Use a marker instead of LiveRange & LivePositions. const urlRange = new LiveRange(leftPosition, rightPosition); const walker = urlRange.getWalker({ ignoreElementEnd: true }); const selectionAttributes = Object.fromEntries(editor.model.document.selection.getAttributes()); const imageUtils = this.editor.plugins.get('ImageUtils'); let src = ''; for (const node of walker){ if (node.item.is('$textProxy')) { src += node.item.data; } } src = src.trim(); // If the URL does not match the image URL regexp, let's skip that. if (!src.match(IMAGE_URL_REGEXP)) { urlRange.detach(); return; } // Position will not be available in the `setTimeout` function so let's clone it. this._positionToInsert = LivePosition.fromPosition(leftPosition); // This action mustn't be executed if undo was called between pasting and auto-embedding. this._timeoutId = setTimeout(()=>{ // Do nothing if image element cannot be inserted at the current position. // See https://github.com/ckeditor/ckeditor5/issues/2763. // Condition must be checked after timeout - pasting may take place on an element, replacing it. The final position matters. const imageCommand = editor.commands.get('insertImage'); if (!imageCommand.isEnabled) { urlRange.detach(); return; } editor.model.change((writer)=>{ this._timeoutId = null; writer.remove(urlRange); urlRange.detach(); let insertionPosition; // Check if the position where the element should be inserted is still valid. // Otherwise leave it as undefined to use the logic of insertImage(). if (this._positionToInsert.root.rootName !== '$graveyard') { insertionPosition = this._positionToInsert.toPosition(); } imageUtils.insertImage({ ...selectionAttributes, src }, insertionPosition); this._positionToInsert.detach(); this._positionToInsert = null; }); const deletePlugin = editor.plugins.get('Delete'); deletePlugin.requestUndoOnBackspace(); }, 100); } /** * @inheritDoc */ constructor(editor){ super(editor); this._timeoutId = null; this._positionToInsert = null; } } class ImageTextAlternativeCommand extends Command { /** * @inheritDoc */ refresh() { const editor = this.editor; const imageUtils = editor.plugins.get('ImageUtils'); const element = imageUtils.getClosestSelectedImageElement(this.editor.model.document.selection); this.isEnabled = !!element; if (this.isEnabled && element.hasAttribute('alt')) { this.value = element.getAttribute('alt'); } else { this.value = false; } } /** * Executes the command. * * @fires execute * @param options * @param options.newValue The new value of the `alt` attribute to set. */ execute(options) { const editor = this.editor; const imageUtils = editor.plugins.get('ImageUtils'); const model = editor.model; const imageElement = imageUtils.getClosestSelectedImageElement(model.document.selection); model.change((writer)=>{ writer.setAttribute('alt', options.newValue, imageElement); }); } } class ImageTextAlternativeEditing extends Plugin { /** * @inheritDoc */ static get requires() { return [ ImageUtils ]; } /** * @inheritDoc */ static get pluginName() { return 'ImageTextAlternativeEditing'; } /** * @inheritDoc */ init() { this.editor.commands.add('imageTextAlternative', new ImageTextAlternativeCommand(this.editor)); } } class TextAlternativeFormView extends View { /** * @inheritDoc */ render() { super.render(); this.keystrokes.listenTo(this.element); submitHandler({ view: this }); [ this.labeledInput, this.saveButtonView, this.cancelButtonView ].forEach((v)=>{ // Register the view as focusable. this._focusables.add(v); // Register the view in the focus tracker. this.focusTracker.add(v.element); }); } /** * @inheritDoc */ destroy() { super.destroy(); this.focusTracker.destroy(); this.keystrokes.destroy(); } /** * Creates the button view. * * @param label The button label * @param icon The button's icon. * @param className The additional button CSS class name. * @param eventName The event name that the ButtonView#execute event will be delegated to. * @returns The button view instance. */ _createButton(label, icon, className, eventName) { const button = new ButtonView(this.locale); button.set({ label, icon, tooltip: true }); button.extendTemplate({ attributes: { class: className } }); if (eventName) { button.delegate('execute').to(this, eventName); } return button; } /** * Creates an input with a label. * * @returns Labeled field view instance. */ _createLabeledInputView() { const t = this.locale.t; const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText); labeledInput.label = t('Text alternative'); return labeledInput; } /** * @inheritDoc */ constructor(locale){ super(locale); const t = this.locale.t; this.focusTracker = new FocusTracker(); this.keystrokes = new KeystrokeHandler(); this.labeledInput = this._createLabeledInputView(); this.saveButtonView = this._createButton(t('Save'), icons.check, 'ck-button-save'); this.saveButtonView.type = 'submit'; this.cancelButtonView = this._createButton(t('Cancel'), icons.cancel, 'ck-button-cancel', 'cancel'); this._focusables = new ViewCollection(); this._focusCycler = new FocusCycler({ focusables: this._focusables, focusTracker: this.focusTracker, keystrokeHandler: this.keystrokes, actions: { // Navigate form fields backwards using the Shift + Tab keystroke. focusPrevious: 'shift + tab', // Navigate form fields forwards using the Tab key. focusNext: 'tab' } }); this.setTemplate({ tag: 'form', attributes: { class: [ 'ck', 'ck-text-alternative-form', 'ck-responsive-form' ], // https://github.com/ckeditor/ckeditor5-image/issues/40 tabindex: '-1' }, children: [ this.labeledInput, this.saveButtonView, this.cancelButtonView ] }); } } /** * A helper utility that positions the * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} instance * with respect to the image in the editor content, if one is selected. * * @param editor The editor instance. */ function repositionContextualBalloon(editor) { const balloon = editor.plugins.get('ContextualBalloon'); const imageUtils = editor.plugins.get('ImageUtils'); if (imageUtils.getClosestSelectedImageWidget(editor.editing.view.document.selection)) { const position = getBalloonPositionData(editor); balloon.updatePosition(position); } } /** * Returns the positioning options that control the geometry of the * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect * to the selected element in the editor content. * * @param editor The editor instance. */ function getBalloonPositionData(editor) { const editingView = editor.editing.view; const defaultPositions = BalloonPanelView.defaultPositions; const imageUtils = editor.plugins.get('ImageUtils'); return { target: editingView.domConverter.mapViewToDom(imageUtils.getClosestSelectedImageWidget(editingView.document.selection)), positions: [ defaultPositions.northArrowSouth, defaultPositions.northArrowSouthWest, defaultPositions.northArrowSouthEast, defaultPositions.southArrowNorth, defaultPositions.southArrowNorthWest, defaultPositions.southArrowNorthEast, defaultPositions.viewportStickyNorth ] }; } class ImageTextAlternativeUI extends Plugin { /** * @inheritDoc */ static get requires() { return [ ContextualBalloon ]; } /** * @inheritDoc */ static get pluginName() { return 'ImageTextAlternativeUI'; } /** * @inheritDoc */ init() { this._createButton(); } /** * @inheritDoc */ destroy() { super.destroy(); // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341). if (this._form) { this._form.destroy(); } } /** * Creates a button showing the balloon panel for changing the image text alternative and * registers it in the editor {@link module:ui/componentfactory~ComponentFactory ComponentFactory}. */ _createButton() { const editor = this.editor; const t = editor.t; editor.ui.componentFactory.add('imageTextAlternative', (locale)=>{ const command = editor.commands.get('imageTextAlternative'); const view = new ButtonView(locale); view.set({ label: t('Change image text alternative'), icon: icons.textAlternative, tooltip: true }); view.bind('isEnabled').to(command, 'isEnabled'); view.bind('isOn').to(command, 'value', (value)=>!!value); this.listenTo(view, 'execute', ()=>{ this._showForm(); }); return view; }); } /** * Creates the {@link module:image/imagetextalternative/ui/textalternativeformview~TextAlternativeFormView} * form. */ _createForm() { const editor = this.editor; const view = editor.editing.view; const viewDocument = view.document; const imageUtils = editor.plugins.get('ImageUtils'); this._balloon = this.editor.plugins.get('ContextualBalloon'); this._form = new (CssTransitionDisablerMixin(TextAlternativeFormView))(editor.locale); // Render the form so its #element is available for clickOutsideHandler. this._form.render(); this.listenTo(this._form, 'submit', ()=>{ editor.execute('imageTextAlternative', { newValue: this._form.labeledInput.fieldView.element.value }); this._hideForm(true); }); this.listenTo(this._form, 'cancel', ()=>{ this._hideForm(true); }); // Close the form on Esc key press. this._form.keystrokes.set('Esc', (data, cancel)=>{ this._hideForm(true); cancel(); }); // Reposition the balloon or hide the form if an image widget is no longer selected. this.listenTo(editor.ui, 'update', ()=>{ if (!imageUtils.getClosestSelectedImageWidget(viewDocument.selection)) { this._hideForm(true); } else if (this._isVisible) { repositionContextualBalloon(editor); } }); // Close on click outside of balloon panel element. clickOutsideHandler({ emitter: this._form, activator: ()=>this._isVisible, contextElements: ()=>[ this._balloon.view.element ], callback: ()=>this._hideForm() }); } /** * Shows the {@link #_form} in the {@link #_balloon}. */ _showForm() { if (this._isVisible) { return; } if (!this._form) { this._createForm(); } const editor = this.editor; const command = editor.commands.get('imageTextAlternative'); const labeledInput = this._form.labeledInput; this._form.disableCssTransitions(); if (!this._isInBalloon) { this._balloon.add({ view: this._form, position: getBalloonPositionData(editor) }); } // Make sure that each time the panel shows up, the field remains in sync with the value of // the command. If the user typed in the input, then canceled the balloon (`labeledInput#value` // stays unaltered) and re-opened it without changing the value of the command, they would see the // old value instead of the actual value of the command. // https://github.com/ckeditor/ckeditor5-image/issues/114 labeledInput.fieldView.value = labeledInput.fieldView.element.value = command.value || ''; this._form.labeledInput.fieldView.select(); this._form.enableCssTransitions(); } /** * Removes the {@link #_form} from the {@link #_balloon}. * * @param focusEditable Controls whether the editing view is focused afterwards. */ _hideForm(focusEditable = false) { if (!this._isInBalloon) { return; } // Blur the input element before removing it from DOM to prevent issues in some browsers. // See https://github.com/ckeditor/ckeditor5/issues/1501. if (this._form.focusTracker.isFocused) { this._form.saveButtonView.focus(); } this._balloon.remove(this._form); if (focusEditable) { this.editor.editing.view.focus(); } } /** * Returns `true` when the {@link #_form} is the visible view in the {@link #_balloon}. */ get _isVisible() { return !!this._balloon && this._balloon.visibleView === this._form; } /** * Returns `true` when the {@link #_form} is in the {@link #_balloon}. */ get _isInBalloon() { return !!this._balloon && this._balloon.hasView(this._form); } } class ImageTextAlternative extends Plugin { /** * @inheritDoc */ static get requires() { return [ ImageTextAlternativeEditing, ImageTextAlternativeUI ]; } /** * @inheritDoc */ static get pluginName() { return 'ImageTextAlternative'; } } /** * 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 */ 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 */ 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. */ 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 */ 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); } } 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. */ 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); }; } class ImageLoadObserver extends Observer { /** * @inheritDoc */ observe(domRoot) { this.listenTo(domRoot, 'load', (event, domEvent)=>{ const domElement = domEvent.target; if (this.checkShouldIgnoreEventFromTarget(domElement)) { return; } if (domElement.tagName == 'IMG') { this._fireEvents(domEvent); } // Use capture phase for better performance (#4504). }, { useCapture: true }); } /** * @inheritDoc */ stopObserving(domRoot) { this.stopListening(domRoot); } /** * Fires {@link module:engine/view/document~Document#event:layoutChanged} and * {@link module:engine/view/document~Document#event:imageLoaded} * if observer {@link #isEnabled is enabled}. * * @param domEvent The DOM event. */ _fireEvents(domEvent) { if (this.isEnabled) { this.document.fire('layoutChanged'); this.document.fire('imageLoaded', domEvent); } } } class InsertImageCommand extends Command { /** * @inheritDoc */ refresh() { const imageUtils = this.editor.plugins.get('ImageUtils'); this.isEnabled = imageUtils.isImageAllowed(); } /** * Executes the command. * * @fires execute * @param options Options for the executed command. * @param options.source The image source or an array of image sources to insert. * See the documentation of the command to learn more about accepted formats. */ execute(options) { const sourceDefinitions = toArray(options.source); const selection = this.editor.model.document.selection; const imageUtils = this.editor.plugins.get('ImageUtils'); // In case of multiple images, each image (starting from the 2nd) will be inserted at a position that // follows the previous one. That will move the selection and, to stay on the safe side and make sure // all images inherit the same selection attributes, they are collected beforehand. // // Applying these attributes ensures, for instance, that inserting an (inline) image into a link does // not split that link but preserves its continuity. // // Note: Selection attributes that do not make sense for images will be filtered out by insertImage() anyway. const selectionAttributes = Object.fromEntries(selection.getAttributes()); sourceDefinitions.forEach((sourceDefinition, index)=>{ const selectedElement = selection.getSelectedElement(); if (typeof sourceDefinition === 'string') { sourceDefinition = { src: sourceDefinition }; } // Inserting of an inline image replace the selected element and make a selection on the inserted image. // Therefore inserting multiple inline images requires creating position after each element. if (index && selectedElement && imageUtils.isImage(selectedElement)) { const position = this.editor.model.createPositionAfter(selectedElement); imageUtils.insertImage({ ...sourceDefinition, ...selectionAttributes }, position); } else { imageUtils.insertImage({ ...sourceDefinition, ...selectionAttributes }); } }); } /** * @inheritDoc */ constructor(editor){