@ckeditor/ckeditor5-image
Version:
Image feature for CKEditor 5.
1,225 lines (1,212 loc) • 227 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, 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){