diff --git a/packages/ckeditor5-image/src/autoimage.js b/packages/ckeditor5-image/src/autoimage.js index 199c42a6bf8..f9df5e9c337 100644 --- a/packages/ckeditor5-image/src/autoimage.js +++ b/packages/ckeditor5-image/src/autoimage.js @@ -13,7 +13,7 @@ import { LivePosition, LiveRange } from 'ckeditor5/src/engine'; import { Undo } from 'ckeditor5/src/undo'; import { global } from 'ckeditor5/src/utils'; -import { insertImage } from './image/utils'; +import ImageUtils from './imageutils'; // 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 + @@ -32,7 +32,7 @@ export default class AutoImage extends Plugin { * @inheritDoc */ static get requires() { - return [ Clipboard, Undo ]; + return [ Clipboard, ImageUtils, Undo ]; } /** @@ -118,6 +118,8 @@ export default class AutoImage extends Plugin { // 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 = ''; @@ -166,7 +168,7 @@ export default class AutoImage extends Plugin { insertionPosition = this._positionToInsert.toPosition(); } - insertImage( editor, { src }, insertionPosition ); + imageUtils.insertImage( { ...selectionAttributes, src }, insertionPosition ); this._positionToInsert.detach(); this._positionToInsert = null; diff --git a/packages/ckeditor5-image/src/image.js b/packages/ckeditor5-image/src/image.js index ab65ac10562..af6f8157190 100644 --- a/packages/ckeditor5-image/src/image.js +++ b/packages/ckeditor5-image/src/image.js @@ -11,7 +11,6 @@ import { Plugin } from 'ckeditor5/src/core'; import ImageBlock from './imageblock'; import ImageInline from './imageinline'; -import { isImageWidget } from './image/utils'; import '../theme/image.css'; @@ -44,16 +43,6 @@ export default class Image extends Plugin { static get pluginName() { return 'Image'; } - - /** - * Checks if a given view element is an image widget. - * - * @param {module:engine/view/element~Element} viewElement - * @returns {Boolean} - */ - isImageWidget( viewElement ) { - return isImageWidget( viewElement ); - } } /** diff --git a/packages/ckeditor5-image/src/image/converters.js b/packages/ckeditor5-image/src/image/converters.js index b36dc63d77e..9b57b9971d2 100644 --- a/packages/ckeditor5-image/src/image/converters.js +++ b/packages/ckeditor5-image/src/image/converters.js @@ -8,7 +8,6 @@ */ import { first } from 'ckeditor5/src/utils'; -import { getViewImageFromWidget } from './utils'; /** * Returns a function that converts the image view representation: @@ -22,9 +21,10 @@ import { getViewImageFromWidget } from './utils'; * The entire content of the `
` element except the first `` is being converted as children * of the `` model element. * + * @param {module:image/imageutils~ImageUtils} imageUtils * @returns {Function} */ -export function viewFigureToModel() { +export function viewFigureToModel( imageUtils ) { return dispatcher => { dispatcher.on( 'element:figure', converter ); }; @@ -36,7 +36,7 @@ export function viewFigureToModel() { } // Find an image element inside the figure element. - const viewImage = getViewImageFromWidget( data.viewItem ); + const viewImage = imageUtils.getViewImageFromWidget( data.viewItem ); // Do not convert if image element is absent, is missing src attribute or was already converted. if ( !viewImage || !viewImage.hasAttribute( 'src' ) || !conversionApi.consumable.test( viewImage, { name: true } ) ) { @@ -64,10 +64,11 @@ export function viewFigureToModel() { /** * Converter used to convert the `srcset` model image attribute to the `srcset`, `sizes` and `width` attributes in the view. * + * @param {module:image/imageutils~ImageUtils} imageUtils * @param {'image'|'imageInline'} imageType The type of the image. * @returns {Function} */ -export function srcsetAttributeConverter( imageType ) { +export function srcsetAttributeConverter( imageUtils, imageType ) { return dispatcher => { dispatcher.on( `attribute:srcset:${ imageType }`, converter ); }; @@ -79,7 +80,7 @@ export function srcsetAttributeConverter( imageType ) { const writer = conversionApi.writer; const element = conversionApi.mapper.toViewElement( data.item ); - const img = getViewImageFromWidget( element ); + const img = imageUtils.getViewImageFromWidget( element ); if ( data.attributeNewValue === null ) { const srcset = data.attributeOldValue; @@ -111,11 +112,12 @@ export function srcsetAttributeConverter( imageType ) { /** * Converter used to convert a given image attribute from the model to the view. * + * @param {module:image/imageutils~ImageUtils} imageUtils * @param {'image'|'imageInline'} imageType The type of the image. * @param {String} attributeKey The name of the attribute to convert. * @returns {Function} */ -export function modelToViewAttributeConverter( imageType, attributeKey ) { +export function modelToViewAttributeConverter( imageUtils, imageType, attributeKey ) { return dispatcher => { dispatcher.on( `attribute:${ attributeKey }:${ imageType }`, converter ); }; @@ -127,7 +129,7 @@ export function modelToViewAttributeConverter( imageType, attributeKey ) { const viewWriter = conversionApi.writer; const element = conversionApi.mapper.toViewElement( data.item ); - const img = getViewImageFromWidget( element ); + const img = imageUtils.getViewImageFromWidget( element ); viewWriter.setAttribute( data.attributeKey, data.attributeNewValue || '', img ); } diff --git a/packages/ckeditor5-image/src/image/imageblockediting.js b/packages/ckeditor5-image/src/image/imageblockediting.js index 0bf565ad89c..f6078217f93 100644 --- a/packages/ckeditor5-image/src/image/imageblockediting.js +++ b/packages/ckeditor5-image/src/image/imageblockediting.js @@ -12,16 +12,15 @@ import { ClipboardPipeline } from 'ckeditor5/src/clipboard'; import { UpcastWriter } from 'ckeditor5/src/engine'; import { modelToViewAttributeConverter, srcsetAttributeConverter, viewFigureToModel } from './converters'; -import { - toImageWidget, - createImageViewElement, - getImageTypeMatcher, - determineImageTypeForInsertionAtSelection, - isInlineImageView -} from './utils'; import ImageEditing from './imageediting'; import ImageTypeCommand from './imagetypecommand'; +import ImageUtils from '../imageutils'; +import { + getImageTypeMatcher, + createImageViewElement, + determineImageTypeForInsertionAtSelection +} from '../image/utils'; /** * The image block plugin. @@ -40,7 +39,7 @@ export default class ImageBlockEditing extends Plugin { * @inheritDoc */ static get requires() { - return [ ImageEditing, ClipboardPipeline ]; + return [ ImageEditing, ImageUtils, ClipboardPipeline ]; } /** @@ -84,6 +83,7 @@ export default class ImageBlockEditing extends Plugin { const editor = this.editor; const t = editor.t; const conversion = editor.conversion; + const imageUtils = editor.plugins.get( 'ImageUtils' ); conversion.for( 'dataDowncast' ) .elementToElement( { @@ -94,23 +94,23 @@ export default class ImageBlockEditing extends Plugin { conversion.for( 'editingDowncast' ) .elementToElement( { model: 'image', - view: ( modelElement, { writer } ) => toImageWidget( + view: ( modelElement, { writer } ) => imageUtils.toImageWidget( createImageViewElement( writer, 'image' ), writer, t( 'image widget' ) ) } ); conversion.for( 'downcast' ) - .add( modelToViewAttributeConverter( 'image', 'src' ) ) - .add( modelToViewAttributeConverter( 'image', 'alt' ) ) - .add( srcsetAttributeConverter( 'image' ) ); + .add( modelToViewAttributeConverter( imageUtils, 'image', 'src' ) ) + .add( modelToViewAttributeConverter( imageUtils, 'image', 'alt' ) ) + .add( srcsetAttributeConverter( imageUtils, 'image' ) ); // More image related upcasts are in 'ImageEditing' plugin. conversion.for( 'upcast' ) .elementToElement( { - view: getImageTypeMatcher( 'image', editor ), + view: getImageTypeMatcher( editor, 'image' ), model: ( viewImage, { writer } ) => writer.createElement( 'image', { src: viewImage.getAttribute( 'src' ) } ) } ) - .add( viewFigureToModel() ); + .add( viewFigureToModel( imageUtils ) ); } /** @@ -132,8 +132,8 @@ export default class ImageBlockEditing extends Plugin { _setupClipboardIntegration() { const editor = this.editor; const model = editor.model; - const schema = model.schema; const editingView = editor.editing.view; + const imageUtils = editor.plugins.get( 'ImageUtils' ); this.listenTo( editor.plugins.get( 'ClipboardPipeline' ), 'inputTransformation', ( evt, data ) => { const docFragmentChildren = Array.from( data.content.getChildren() ); @@ -141,7 +141,7 @@ export default class ImageBlockEditing extends Plugin { // Make sure only elements are dropped or pasted. Otherwise, if there some other HTML // mixed up, this should be handled as a regular paste. - if ( !docFragmentChildren.every( isInlineImageView ) ) { + if ( !docFragmentChildren.every( imageUtils.isInlineImageView ) ) { return; } @@ -160,7 +160,7 @@ export default class ImageBlockEditing extends Plugin { // Convert inline images into block images only when the currently selected block is empty // (e.g. an empty paragraph) or some object is selected (to replace it). - if ( determineImageTypeForInsertionAtSelection( schema, selection ) === 'image' ) { + if ( determineImageTypeForInsertionAtSelection( model.schema, selection ) === 'image' ) { const writer = new UpcastWriter( editingView.document ); // Wrap ->
diff --git a/packages/ckeditor5-image/src/image/imageediting.js b/packages/ckeditor5-image/src/image/imageediting.js index 45baf9ae9b3..6ae57271b5a 100644 --- a/packages/ckeditor5-image/src/image/imageediting.js +++ b/packages/ckeditor5-image/src/image/imageediting.js @@ -10,6 +10,7 @@ import { Plugin } from 'ckeditor5/src/core'; import ImageLoadObserver from './imageloadobserver'; import InsertImageCommand from './insertimagecommand'; +import ImageUtils from '../imageutils'; /** * The image engine plugin. This module loads common code shared between @@ -21,6 +22,13 @@ import InsertImageCommand from './insertimagecommand'; * @extends module:core/plugin~Plugin */ export default class ImageEditing extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ ImageUtils ]; + } + /** * @inheritDoc */ diff --git a/packages/ckeditor5-image/src/image/imageinlineediting.js b/packages/ckeditor5-image/src/image/imageinlineediting.js index 74aabce58b8..2d3536b9b56 100644 --- a/packages/ckeditor5-image/src/image/imageinlineediting.js +++ b/packages/ckeditor5-image/src/image/imageinlineediting.js @@ -11,18 +11,16 @@ import { Plugin } from 'ckeditor5/src/core'; import { ClipboardPipeline } from 'ckeditor5/src/clipboard'; import { UpcastWriter } from 'ckeditor5/src/engine'; -import { - toImageWidget, - createImageViewElement, - getImageTypeMatcher, - getViewImageFromWidget, - determineImageTypeForInsertionAtSelection, - isBlockImageView -} from './utils'; import { modelToViewAttributeConverter, srcsetAttributeConverter } from './converters'; import ImageEditing from './imageediting'; import ImageTypeCommand from './imagetypecommand'; +import ImageUtils from '../imageutils'; +import { + getImageTypeMatcher, + createImageViewElement, + determineImageTypeForInsertionAtSelection +} from '../image/utils'; /** * The image inline plugin. @@ -41,7 +39,7 @@ export default class ImageInlineEditing extends Plugin { * @inheritDoc */ static get requires() { - return [ ImageEditing, ClipboardPipeline ]; + return [ ImageEditing, ImageUtils, ClipboardPipeline ]; } /** @@ -85,6 +83,7 @@ export default class ImageInlineEditing extends Plugin { const editor = this.editor; const t = editor.t; const conversion = editor.conversion; + const imageUtils = editor.plugins.get( 'ImageUtils' ); conversion.for( 'dataDowncast' ) .elementToElement( { @@ -95,20 +94,20 @@ export default class ImageInlineEditing extends Plugin { conversion.for( 'editingDowncast' ) .elementToElement( { model: 'imageInline', - view: ( modelElement, { writer } ) => toImageWidget( + view: ( modelElement, { writer } ) => imageUtils.toImageWidget( createImageViewElement( writer, 'imageInline' ), writer, t( 'inline image widget' ) ) } ); conversion.for( 'downcast' ) - .add( modelToViewAttributeConverter( 'imageInline', 'src' ) ) - .add( modelToViewAttributeConverter( 'imageInline', 'alt' ) ) - .add( srcsetAttributeConverter( 'imageInline' ) ); + .add( modelToViewAttributeConverter( imageUtils, 'imageInline', 'src' ) ) + .add( modelToViewAttributeConverter( imageUtils, 'imageInline', 'alt' ) ) + .add( srcsetAttributeConverter( imageUtils, 'imageInline' ) ); // More image related upcasts are in 'ImageEditing' plugin. conversion.for( 'upcast' ) .elementToElement( { - view: getImageTypeMatcher( 'imageInline', editor ), + view: getImageTypeMatcher( editor, 'imageInline' ), model: ( viewImage, { writer } ) => writer.createElement( 'imageInline', { src: viewImage.getAttribute( 'src' ) } ) } ); } @@ -133,8 +132,8 @@ export default class ImageInlineEditing extends Plugin { _setupClipboardIntegration() { const editor = this.editor; const model = editor.model; - const schema = model.schema; const editingView = editor.editing.view; + const imageUtils = editor.plugins.get( 'ImageUtils' ); this.listenTo( editor.plugins.get( 'ClipboardPipeline' ), 'inputTransformation', ( evt, data ) => { const docFragmentChildren = Array.from( data.content.getChildren() ); @@ -142,7 +141,7 @@ export default class ImageInlineEditing extends Plugin { // Make sure only
elements are dropped or pasted. Otherwise, if there some other HTML // mixed up, this should be handled as a regular paste. - if ( !docFragmentChildren.every( isBlockImageView ) ) { + if ( !docFragmentChildren.every( imageUtils.isBlockImageView ) ) { return; } @@ -161,17 +160,18 @@ export default class ImageInlineEditing extends Plugin { // Convert block images into inline images only when pasting or dropping into non-empty blocks // and when the block is not an object (e.g. pasting to replace another widget). - if ( determineImageTypeForInsertionAtSelection( schema, selection ) === 'imageInline' ) { + if ( determineImageTypeForInsertionAtSelection( model.schema, selection ) === 'imageInline' ) { const writer = new UpcastWriter( editingView.document ); // Unwrap
-> // but
...
-> stays the same const inlineViewImages = docFragmentChildren.map( blockViewImage => { + // If there's just one child, it can be either or . // If there are other children than , this means that the block image // has a caption or some other features and this kind of image should be // pasted/dropped without modifications. if ( blockViewImage.childCount === 1 ) { - return getViewImageFromWidget( blockViewImage ); + return blockViewImage.getChild( 0 ); } else { return blockViewImage; } diff --git a/packages/ckeditor5-image/src/image/imagetypecommand.js b/packages/ckeditor5-image/src/image/imagetypecommand.js index 6e2617636ab..77970424b38 100644 --- a/packages/ckeditor5-image/src/image/imagetypecommand.js +++ b/packages/ckeditor5-image/src/image/imagetypecommand.js @@ -8,7 +8,6 @@ */ import { Command } from 'ckeditor5/src/core'; -import { insertImage, isBlockImage, isInlineImage, getClosestSelectedImageElement } from './utils'; /** * The image type command. It changes the type of a selected image, depending on the configuration. @@ -39,12 +38,14 @@ export default class ImageTypeCommand extends Command { * @inheritDoc */ refresh() { - const element = getClosestSelectedImageElement( this.editor.model.document.selection ); + const editor = this.editor; + const imageUtils = editor.plugins.get( 'ImageUtils' ); + const element = imageUtils.getClosestSelectedImageElement( this.editor.model.document.selection ); if ( this._modelElementName === 'image' ) { - this.isEnabled = isInlineImage( element ); + this.isEnabled = imageUtils.isInlineImage( element ); } else { - this.isEnabled = isBlockImage( element ); + this.isEnabled = imageUtils.isBlockImage( element ); } } @@ -52,14 +53,16 @@ export default class ImageTypeCommand extends Command { * @inheritDoc */ execute() { + const editor = this.editor; const model = this.editor.model; - const imageElement = getClosestSelectedImageElement( model.document.selection ); + const imageUtils = editor.plugins.get( 'ImageUtils' ); + const imageElement = imageUtils.getClosestSelectedImageElement( model.document.selection ); const attributes = Object.fromEntries( imageElement.getAttributes() ); if ( !attributes.src ) { return; } - insertImage( this.editor, attributes, model.createSelection( imageElement, 'on' ), this._modelElementName ); + imageUtils.insertImage( attributes, model.createSelection( imageElement, 'on' ), this._modelElementName ); } } diff --git a/packages/ckeditor5-image/src/image/insertimagecommand.js b/packages/ckeditor5-image/src/image/insertimagecommand.js index 25f7b59f219..1d4a3cf9277 100644 --- a/packages/ckeditor5-image/src/image/insertimagecommand.js +++ b/packages/ckeditor5-image/src/image/insertimagecommand.js @@ -6,8 +6,6 @@ import { Command } from 'ckeditor5/src/core'; import { logWarning, toArray } from 'ckeditor5/src/utils'; -import { insertImage, isImage, isImageAllowed } from './utils'; - /** * @module image/image/insertimagecommand */ @@ -73,7 +71,7 @@ export default class InsertImageCommand extends Command { * @inheritDoc */ refresh() { - this.isEnabled = isImageAllowed( this.editor ); + this.isEnabled = this.editor.plugins.get( 'ImageUtils' ).isImageAllowed(); } /** @@ -86,18 +84,29 @@ export default class InsertImageCommand extends Command { execute( options ) { const sources = 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() ); sources.forEach( ( src, index ) => { const selectedElement = selection.getSelectedElement(); // 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 && isImage( selectedElement ) ) { + if ( index && selectedElement && imageUtils.isImage( selectedElement ) ) { const position = this.editor.model.createPositionAfter( selectedElement ); - insertImage( this.editor, { src }, position ); + imageUtils.insertImage( { src, ...selectionAttributes }, position ); } else { - insertImage( this.editor, { src } ); + imageUtils.insertImage( { src, ...selectionAttributes } ); } } ); } diff --git a/packages/ckeditor5-image/src/image/ui/utils.js b/packages/ckeditor5-image/src/image/ui/utils.js index 166602b825d..30d80dc7fa1 100644 --- a/packages/ckeditor5-image/src/image/ui/utils.js +++ b/packages/ckeditor5-image/src/image/ui/utils.js @@ -8,7 +8,6 @@ */ import { BalloonPanelView } from 'ckeditor5/src/ui'; -import { getClosestSelectedImageWidget } from '../utils'; /** * A helper utility that positions the @@ -20,7 +19,7 @@ import { getClosestSelectedImageWidget } from '../utils'; export function repositionContextualBalloon( editor ) { const balloon = editor.plugins.get( 'ContextualBalloon' ); - if ( getClosestSelectedImageWidget( editor.editing.view.document.selection ) ) { + if ( editor.plugins.get( 'ImageUtils' ).getClosestSelectedImageWidget( editor.editing.view.document.selection ) ) { const position = getBalloonPositionData( editor ); balloon.updatePosition( position ); @@ -38,9 +37,10 @@ export function repositionContextualBalloon( editor ) { export function getBalloonPositionData( editor ) { const editingView = editor.editing.view; const defaultPositions = BalloonPanelView.defaultPositions; + const imageUtils = editor.plugins.get( 'ImageUtils' ); return { - target: editingView.domConverter.viewToDom( getClosestSelectedImageWidget( editingView.document.selection ) ), + target: editingView.domConverter.viewToDom( imageUtils.getClosestSelectedImageWidget( editingView.document.selection ) ), positions: [ defaultPositions.northArrowSouth, defaultPositions.northArrowSouthWest, diff --git a/packages/ckeditor5-image/src/image/utils.js b/packages/ckeditor5-image/src/image/utils.js index f9f35f73443..f6aba3c4f9b 100644 --- a/packages/ckeditor5-image/src/image/utils.js +++ b/packages/ckeditor5-image/src/image/utils.js @@ -7,238 +7,22 @@ * @module image/image/utils */ -import { findOptimalInsertionRange, isWidget, toWidget } from 'ckeditor5/src/widget'; import { first } from 'ckeditor5/src/utils'; -/** - * 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 {module:engine/view/element~Element} viewElement - * @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer. - * @param {String} label The element's label. It will be concatenated with the image `alt` attribute if one is present. - * @returns {module:engine/view/element~Element} - */ -export function toImageWidget( viewElement, writer, label ) { - writer.setCustomProperty( 'image', true, viewElement ); - - return toWidget( viewElement, writer, { label: labelCreator } ); - - function labelCreator() { - const imgElement = getViewImageFromWidget( viewElement ); - const altText = imgElement.getAttribute( 'alt' ); - - return altText ? `${ altText } ${ label }` : label; - } -} - -/** - * Checks if a given view element is an image widget. - * - * @param {module:engine/view/element~Element} viewElement - * @returns {Boolean} - */ -export function isImageWidget( viewElement ) { - return !!viewElement.getCustomProperty( 'image' ) && isWidget( viewElement ); -} - -/** - * Returns an image widget editing view element if one is selected or is among the selection's ancestors. - * - * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection - * @returns {module:engine/view/element~Element|null} - */ -export function getClosestSelectedImageWidget( selection ) { - const viewElement = selection.getSelectedElement(); - - if ( viewElement && isImageWidget( viewElement ) ) { - return viewElement; - } - - let parent = selection.getFirstPosition().parent; - - while ( parent ) { - if ( parent.is( 'element' ) && 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. - * - * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection - * @returns {module:engine/model/element~Element|null} - */ - -export function getClosestSelectedImageElement( selection ) { - const selectedElement = selection.getSelectedElement(); - - return isImage( selectedElement ) ? selectedElement : selection.getFirstPosition().findAncestor( 'image' ); -} - -/** - * Checks if the provided model element is an `image`. - * - * @protected - * @param {module:engine/model/element~Element} modelElement - * @returns {Boolean} - */ -export function isBlockImage( modelElement ) { - return !!modelElement && modelElement.is( 'element', 'image' ); -} - -/** - * Checks if the provided model element is an `imageInline`. - * - * @protected - * @param {module:engine/model/element~Element} modelElement - * @returns {Boolean} - */ -export function isInlineImage( modelElement ) { - return !!modelElement && modelElement.is( 'element', 'imageInline' ); -} - -/** - * Checks if the provided model element is an `image` or `imageInline`. - * - * @protected - * @param {module:engine/model/element~Element} modelElement - * @returns {Boolean} - */ -export function isImage( modelElement ) { - return isInlineImage( modelElement ) || isBlockImage( modelElement ); -} - -/** - * Checks if the provided view element represents an inline image. - * - * Also, see {@link module:image/image/utils~isImageWidget}. - * - * @param {module:engine/view/element~Element} element - * @returns {Boolean} - */ -export function isInlineImageView( element ) { - return !!element && element.is( 'element', 'img' ); -} - -/** - * Checks if the provided view element represents a block image. - * - * Also, see {@link module:image/image/utils~isImageWidget}. - * - * @param {module:engine/view/element~Element} element - * @returns {Boolean} - */ -export function 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. - * - * insertImage( model, { src: 'path/to/image.jpg' } ); - * - * @param {module:core/editor/editor~Editor} editor - * @param {Object} [attributes={}] Attributes of the inserted image. - * This method filters out the attributes which are disallowed by the {@link module:engine/model/schema~Schema}. - * @param {module:engine/model/selection~Selectable} [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. - * @param {'image'|'imageInline'} [imageType] Image type of inserted image. If not specified, - * it will be determined automatically depending of editor config or place of the insertion. - */ -export function insertImage( editor, attributes = {}, selectable = null, imageType = null ) { - const model = editor.model; - const selection = model.document.selection; - - imageType = determineImageTypeForInsertion( editor, selectable || selection, imageType ); - - for ( const attributeName in attributes ) { - if ( !model.schema.checkAttribute( imageType, attributeName ) ) { - delete attributes[ attributeName ]; - } - } - - model.change( writer => { - const imageElement = writer.createElement( imageType, attributes ); - - // 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). - if ( !selectable && imageType != 'imageInline' ) { - selectable = findOptimalInsertionRange( selection, model ); - } - - model.insertContent( imageElement, selectable ); - - // Inserting an image might've failed due to schema regulations. - if ( imageElement.parent ) { - writer.setSelection( imageElement, 'on' ); - } - } ); -} - -/** - * Checks if image can be inserted at current model selection. - * - * @param {module:core/editor/editor~Editor} editor - * @returns {Boolean} - */ -export function isImageAllowed( editor ) { - const model = editor.model; - const schema = model.schema; - const selection = model.document.selection; - - return isImageAllowedInParent( selection, schema, editor ) && isNotInsideImage( selection ); -} - -/** - * Get view `` element from the view widget (`
`). - * - * Assuming that image is always a first child of a widget (ie. `figureView.getChild( 0 )`) is unsafe as other features might - * inject their own elements to the widget. - * - * The `` can be wrapped to other elements, e.g. ``. Nested check required. - * - * @param {module:engine/view/element~Element} figureView - * @returns {module:engine/view/element~Element} - */ -export function getViewImageFromWidget( figureView ) { - if ( isInlineImageView( figureView ) ) { - return figureView; - } - - const figureChildren = []; - - for ( const figureChild of figureView.getChildren() ) { - figureChildren.push( figureChild ); - - if ( figureChild.is( 'element' ) ) { - figureChildren.push( ...figureChild.getChildren() ); - } - } - - return figureChildren.find( isInlineImageView ); -} - /** * Creates a view element representing the image of provided image type. * * An 'image' type (block image): * - *
+ *
* * An 'imageInline' type (inline image): * - * + * * * Note that `alt` and `src` attributes are converted separately, so they are not included. * + * @protected * @param {module:engine/view/downcastwriter~DowncastWriter} writer * @param {'image'|'imageInline'} imageType The type of created image. * @returns {module:engine/view/containerelement~ContainerElement} @@ -248,7 +32,7 @@ export function createImageViewElement( writer, imageType ) { const container = imageType === 'image' ? writer.createContainerElement( 'figure', { class: 'image' } ) : - writer.createContainerElement( 'span', { class: 'image-inline' } ); + writer.createContainerElement( 'span', { class: 'image-inline' }, { isAllowedInsideAttributeElement: true } ); writer.insert( writer.createPositionAt( container, 0 ), emptyElement ); @@ -258,11 +42,12 @@ export function createImageViewElement( writer, imageType ) { /** * A function returning a `MatcherPattern` for a particular type of View images. * + * @protected + * @param {module:core/editor/editor~Editor} editor * @param {'image'|'imageInline'} matchImageType The type of created image. - * @param {module:core/editor/editor~Editor} editor The editor instance. * @returns {module:engine/view/matcher~MatcherPattern} */ -export function getImageTypeMatcher( matchImageType, editor ) { +export function getImageTypeMatcher( editor, matchImageType ) { if ( editor.plugins.has( 'ImageInlineEditing' ) !== editor.plugins.has( 'ImageBlockEditing' ) ) { return { name: 'img', @@ -272,15 +57,17 @@ export function getImageTypeMatcher( matchImageType, editor ) { }; } + const imageUtils = editor.plugins.get( 'ImageUtils' ); + return element => { // Convert only images with src attribute. - if ( !isInlineImageView( element ) || !element.hasAttribute( 'src' ) ) { + if ( !imageUtils.isInlineImageView( element ) || !element.hasAttribute( 'src' ) ) { return null; } // The can be standalone, wrapped in
...
(ImageBlock plugin) or // wrapped in
...
(LinkImage plugin). - const imageType = element.findAncestor( isBlockImageView ) ? 'image' : 'imageInline'; + const imageType = element.findAncestor( imageUtils.isBlockImageView ) ? 'image' : 'imageInline'; if ( imageType !== matchImageType ) { return null; @@ -299,6 +86,7 @@ export function getImageTypeMatcher( matchImageType, editor ) { * produce block images. Inline images should be inserted in other cases, e.g. in paragraphs * that already contain some text. * + * @protected * @param {module:engine/model/schema~Schema} schema * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection * @returns {'image'|'imageInline'} @@ -308,88 +96,3 @@ export function determineImageTypeForInsertionAtSelection( schema, selection ) { return ( !firstBlock || firstBlock.isEmpty || schema.isObject( firstBlock ) ) ? 'image' : 'imageInline'; } - -// Checks if image is allowed by schema in optimal insertion parent. -// -// @param {module:engine/model/selection~Selection} selection -// @param {module:engine/model/schema~Schema} schema -// @param {module:core/editor/editor~Editor} editor -// @returns {Boolean} -function isImageAllowedInParent( selection, schema, editor ) { - const imageType = determineImageTypeForInsertion( editor, selection ); - - if ( imageType == 'image' ) { - const parent = getInsertImageParent( selection, editor.model ); - - if ( schema.checkChild( parent, 'image' ) ) { - return true; - } - } else if ( schema.checkChild( selection.focus, 'imageInline' ) ) { - return true; - } - - return false; -} - -// Checks if selection is not placed inside an image (e.g. its caption). -// -// @param {module:engine/model/selection~Selectable} selection -// @returns {Boolean} -function isNotInsideImage( selection ) { - return [ ...selection.focus.getAncestors() ].every( ancestor => !ancestor.is( 'element', 'image' ) ); -} - -// Returns a node that will be used to insert image with `model.insertContent`. -// -// @param {module:engine/model/selection~Selectable} selection -// @param {module:engine/model/model~Model} model -// @returns {module:engine/model/element~Element} -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 {module:core/editor/editor~Editor} editor -// @param {module:engine/model/selection~Selectable} selectable -// @param {'image'|'imageInline'} [imageType] Image element type name. Used to force return of provided element name, -// but only if there is proper plugin enabled. -// @returns {'image'|'imageInline'} imageType -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 'image'; - } - - if ( imageType ) { - return imageType; - } - - if ( configImageInsertType === 'inline' ) { - return 'imageInline'; - } - - if ( configImageInsertType === 'block' ) { - return 'image'; - } - - // Try to replace the selected widget (e.g. another image). - if ( selectable.is( 'selection' ) ) { - return determineImageTypeForInsertionAtSelection( schema, selectable ); - } - - return schema.checkChild( selectable, 'imageInline' ) ? 'imageInline' : 'image'; -} diff --git a/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js b/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js index adbd4ac8e25..7b9c3fbeebc 100644 --- a/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js +++ b/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js @@ -14,8 +14,8 @@ import { toWidgetEditable } from 'ckeditor5/src/widget'; import ToggleImageCaptionCommand from './toggleimagecaptioncommand'; import ImageInlineEditing from '../image/imageinlineediting'; import ImageBlockEditing from '../image/imageblockediting'; +import ImageUtils from '../imageutils'; -import { isBlockImage } from '../image/utils'; import { matchImageCaptionViewElement } from './utils'; /** @@ -28,6 +28,13 @@ import { matchImageCaptionViewElement } from './utils'; * @extends module:core/plugin~Plugin */ export default class ImageCaptionEditing extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ ImageUtils ]; + } + /** * @inheritDoc */ @@ -42,6 +49,7 @@ export default class ImageCaptionEditing extends Plugin { const editor = this.editor; const view = editor.editing.view; const schema = editor.model.schema; + const imageUtils = editor.plugins.get( 'ImageUtils' ); const t = editor.t; // Schema configuration. @@ -67,7 +75,7 @@ export default class ImageCaptionEditing extends Plugin { // View -> model converter for the data pipeline. editor.conversion.for( 'upcast' ).elementToElement( { - view: matchImageCaptionViewElement, + view: element => matchImageCaptionViewElement( imageUtils, element ), model: 'caption' } ); @@ -75,7 +83,7 @@ export default class ImageCaptionEditing extends Plugin { editor.conversion.for( 'dataDowncast' ).elementToElement( { model: 'caption', view: ( modelElement, { writer } ) => { - if ( !isBlockImage( modelElement.parent ) ) { + if ( !imageUtils.isBlockImage( modelElement.parent ) ) { return null; } @@ -87,7 +95,7 @@ export default class ImageCaptionEditing extends Plugin { editor.conversion.for( 'editingDowncast' ).elementToElement( { model: 'caption', view: ( modelElement, { writer } ) => { - if ( !isBlockImage( modelElement.parent ) ) { + if ( !imageUtils.isBlockImage( modelElement.parent ) ) { return null; } diff --git a/packages/ckeditor5-image/src/imagecaption/imagecaptionui.js b/packages/ckeditor5-image/src/imagecaption/imagecaptionui.js index 37856c1108f..2a6f1f0c5f2 100644 --- a/packages/ckeditor5-image/src/imagecaption/imagecaptionui.js +++ b/packages/ckeditor5-image/src/imagecaption/imagecaptionui.js @@ -9,6 +9,7 @@ import { Plugin, icons } from 'ckeditor5/src/core'; import { ButtonView } from 'ckeditor5/src/ui'; +import ImageUtils from '../imageutils'; import { getCaptionFromModelSelection } from './utils'; @@ -18,6 +19,13 @@ import { getCaptionFromModelSelection } from './utils'; * @extends module:core/plugin~Plugin */ export default class ImageCaptionUI extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ ImageUtils ]; + } + /** * @inheritDoc */ @@ -31,6 +39,7 @@ export default class ImageCaptionUI extends Plugin { init() { const editor = this.editor; const editingView = editor.editing.view; + const imageUtils = editor.plugins.get( 'ImageUtils' ); const t = editor.t; editor.ui.componentFactory.add( 'toggleImageCaption', locale => { @@ -51,7 +60,7 @@ export default class ImageCaptionUI extends Plugin { // Scroll to the selection and highlight the caption if the caption showed up. if ( command.value ) { - const modelCaptionElement = getCaptionFromModelSelection( editor.model.document.selection ); + const modelCaptionElement = getCaptionFromModelSelection( imageUtils, editor.model.document.selection ); const figcaptionElement = editor.editing.mapper.toViewElement( modelCaptionElement ); editingView.scrollToTheSelection(); diff --git a/packages/ckeditor5-image/src/imagecaption/toggleimagecaptioncommand.js b/packages/ckeditor5-image/src/imagecaption/toggleimagecaptioncommand.js index eee0334174c..9d9b5354f51 100644 --- a/packages/ckeditor5-image/src/imagecaption/toggleimagecaptioncommand.js +++ b/packages/ckeditor5-image/src/imagecaption/toggleimagecaptioncommand.js @@ -11,7 +11,6 @@ import { Command } from 'ckeditor5/src/core'; import { Element } from 'ckeditor5/src/engine'; import ImageBlockEditing from '../image/imageblockediting'; -import { isImage, isInlineImage } from '../image/utils'; import { getCaptionFromImageModelElement, getCaptionFromModelSelection } from './utils'; /** @@ -43,6 +42,7 @@ export default class ToggleImageCaptionCommand extends Command { */ refresh() { const editor = this.editor; + const imageUtils = editor.plugins.get( 'ImageUtils' ); // Only block images can get captions. if ( !editor.plugins.has( ImageBlockEditing ) ) { @@ -56,7 +56,7 @@ export default class ToggleImageCaptionCommand extends Command { const selectedElement = selection.getSelectedElement(); if ( !selectedElement ) { - const ancestorCaptionElement = getCaptionFromModelSelection( selection ); + const ancestorCaptionElement = getCaptionFromModelSelection( imageUtils, selection ); this.isEnabled = !!ancestorCaptionElement; this.value = !!ancestorCaptionElement; @@ -66,7 +66,7 @@ export default class ToggleImageCaptionCommand extends Command { // Block images support captions by default but the command should also be enabled for inline // images because toggling the caption when one is selected should convert it into a block image. - this.isEnabled = isImage( selectedElement ); + this.isEnabled = this.editor.plugins.get( 'ImageUtils' ).isImage( selectedElement ); if ( !this.isEnabled ) { this.value = false; @@ -114,7 +114,7 @@ export default class ToggleImageCaptionCommand extends Command { let newCaptionElement; // Convert imageInline -> image first. - if ( isInlineImage( selectedImage ) ) { + if ( this.editor.plugins.get( 'ImageUtils' ).isInlineImage( selectedImage ) ) { this.editor.execute( 'imageTypeBlock' ); // Executing the command created a new model element. Let's pick it again. @@ -148,15 +148,17 @@ export default class ToggleImageCaptionCommand extends Command { * @param {module:engine/model/writer~Writer} writer */ _hideImageCaption( writer ) { - const model = this.editor.model; + const editor = this.editor; + const model = editor.model; const selection = model.document.selection; + const imageUtils = editor.plugins.get( 'ImageUtils' ); let selectedImage = selection.getSelectedElement(); let captionElement; if ( selectedImage ) { captionElement = getCaptionFromImageModelElement( selectedImage ); } else { - captionElement = getCaptionFromModelSelection( selection ); + captionElement = getCaptionFromModelSelection( imageUtils, selection ); selectedImage = captionElement.parent; } diff --git a/packages/ckeditor5-image/src/imagecaption/utils.js b/packages/ckeditor5-image/src/imagecaption/utils.js index d881395dabf..c6a1adad2ee 100644 --- a/packages/ckeditor5-image/src/imagecaption/utils.js +++ b/packages/ckeditor5-image/src/imagecaption/utils.js @@ -7,8 +7,6 @@ * @module image/imagecaption/utils */ -import { isBlockImage, isBlockImageView } from '../image/utils'; - /** * Returns the caption model element from a given image element. Returns `null` if no caption is found. * @@ -28,17 +26,18 @@ export function getCaptionFromImageModelElement( imageModelElement ) { /** * Returns the caption model element for a model selection. Returns `null` if the selection has no caption element ancestor. * + * @param {module:image/imageutils~ImageUtils} imageUtils * @param {module:engine/model/selection~Selection} selection * @returns {module:engine/model/element~Element|null} */ -export function getCaptionFromModelSelection( selection ) { +export function getCaptionFromModelSelection( imageUtils, selection ) { const captionElement = selection.getFirstPosition().findAncestor( 'caption' ); if ( !captionElement ) { return null; } - if ( isBlockImage( captionElement.parent ) ) { + if ( imageUtils.isBlockImage( captionElement.parent ) ) { return captionElement; } @@ -49,13 +48,14 @@ export function getCaptionFromModelSelection( selection ) { * {@link module:engine/view/matcher~Matcher} pattern. Checks if a given element is a `
` element that is placed * inside the image `
` element. * + * @param {module:image/imageutils~ImageUtils} imageUtils * @param {module:engine/view/element~Element} element * @returns {Object|null} Returns the object accepted by {@link module:engine/view/matcher~Matcher} or `null` if the element * cannot be matched. */ -export function matchImageCaptionViewElement( element ) { +export function matchImageCaptionViewElement( imageUtils, element ) { // Convert only captions for images. - if ( element.name == 'figcaption' && isBlockImageView( element.parent ) ) { + if ( element.name == 'figcaption' && imageUtils.isBlockImageView( element.parent ) ) { return { name: true }; } diff --git a/packages/ckeditor5-image/src/imageinsert/imageinsertui.js b/packages/ckeditor5-image/src/imageinsert/imageinsertui.js index d24df64426c..bbd967afbf0 100644 --- a/packages/ckeditor5-image/src/imageinsert/imageinsertui.js +++ b/packages/ckeditor5-image/src/imageinsert/imageinsertui.js @@ -11,8 +11,6 @@ import { Plugin } from 'ckeditor5/src/core'; import ImageInsertPanelView from './ui/imageinsertpanelview'; import { prepareIntegrations } from './utils'; -import { isImage } from '../image/utils'; - /** * The image insert dropdown plugin. * @@ -92,6 +90,7 @@ export default class ImageInsertUI extends Plugin { const insertButtonView = imageInsertView.insertButtonView; const insertImageViaUrlForm = imageInsertView.getIntegration( 'insertImageViaUrl' ); const panelView = dropdownView.panelView; + const imageUtils = this.editor.plugins.get( 'ImageUtils' ); dropdownView.bind( 'isEnabled' ).to( command ); @@ -107,7 +106,7 @@ export default class ImageInsertUI extends Plugin { if ( dropdownView.isOpen ) { imageInsertView.focus(); - if ( isImage( selectedElement ) ) { + if ( imageUtils.isImage( selectedElement ) ) { imageInsertView.imageURLInputValue = selectedElement.getAttribute( 'src' ); insertButtonView.label = t( 'Update' ); insertImageViaUrlForm.label = t( 'Update image URL' ); @@ -137,7 +136,7 @@ export default class ImageInsertUI extends Plugin { function onSubmit() { const selectedElement = editor.model.document.selection.getSelectedElement(); - if ( isImage( selectedElement ) ) { + if ( imageUtils.isImage( selectedElement ) ) { editor.model.change( writer => { writer.setAttribute( 'src', imageInsertView.imageURLInputValue, selectedElement ); writer.removeAttribute( 'srcset', selectedElement ); diff --git a/packages/ckeditor5-image/src/imageresize/imageresizeediting.js b/packages/ckeditor5-image/src/imageresize/imageresizeediting.js index 40a82d0a983..90eabffb3ba 100644 --- a/packages/ckeditor5-image/src/imageresize/imageresizeediting.js +++ b/packages/ckeditor5-image/src/imageresize/imageresizeediting.js @@ -8,6 +8,7 @@ */ import { Plugin } from 'ckeditor5/src/core'; +import ImageUtils from '../imageutils'; import ResizeImageCommand from './resizeimagecommand'; /** @@ -19,6 +20,13 @@ import ResizeImageCommand from './resizeimagecommand'; * @extends module:core/plugin~Plugin */ export default class ImageResizeEditing extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ ImageUtils ]; + } + /** * @inheritDoc */ diff --git a/packages/ckeditor5-image/src/imageresize/resizeimagecommand.js b/packages/ckeditor5-image/src/imageresize/resizeimagecommand.js index 93ce1f2fc18..7a04bdde4b9 100644 --- a/packages/ckeditor5-image/src/imageresize/resizeimagecommand.js +++ b/packages/ckeditor5-image/src/imageresize/resizeimagecommand.js @@ -8,7 +8,6 @@ */ import { Command } from 'ckeditor5/src/core'; -import { isImage, getClosestSelectedImageElement } from '../image/utils'; /** * The resize image command. Currently, it only supports the width attribute. @@ -20,9 +19,11 @@ export default class ResizeImageCommand extends Command { * @inheritDoc */ refresh() { - const element = getClosestSelectedImageElement( this.editor.model.document.selection ); + const editor = this.editor; + const imageUtils = editor.plugins.get( 'ImageUtils' ); + const element = imageUtils.getClosestSelectedImageElement( editor.model.document.selection ); - this.isEnabled = isImage( element ); + this.isEnabled = !!element; if ( !element || !element.hasAttribute( 'width' ) ) { this.value = null; @@ -48,8 +49,10 @@ export default class ResizeImageCommand extends Command { * @fires execute */ execute( options ) { - const model = this.editor.model; - const imageElement = getClosestSelectedImageElement( model.document.selection ); + const editor = this.editor; + const model = editor.model; + const imageUtils = editor.plugins.get( 'ImageUtils' ); + const imageElement = imageUtils.getClosestSelectedImageElement( model.document.selection ); this.value = { width: options.width, diff --git a/packages/ckeditor5-image/src/imagestyle/imagestylecommand.js b/packages/ckeditor5-image/src/imagestyle/imagestylecommand.js index d3d9f173b8b..d1015987a3b 100644 --- a/packages/ckeditor5-image/src/imagestyle/imagestylecommand.js +++ b/packages/ckeditor5-image/src/imagestyle/imagestylecommand.js @@ -8,7 +8,6 @@ */ import { Command } from 'ckeditor5/src/core'; -import { getClosestSelectedImageElement } from '../image/utils'; /** * The image style command. It is used to apply {@link module:image/imagestyle~ImageStyleConfig#arrangements style arrangements} @@ -65,7 +64,9 @@ export default class ImageStyleCommand extends Command { * @inheritDoc */ refresh() { - const element = getClosestSelectedImageElement( this.editor.model.document.selection ); + const editor = this.editor; + const imageUtils = editor.plugins.get( 'ImageUtils' ); + const element = imageUtils.getClosestSelectedImageElement( this.editor.model.document.selection ); this.isEnabled = !!element; @@ -93,20 +94,22 @@ export default class ImageStyleCommand extends Command { * @fires execute */ execute( options ) { - const model = this.editor.model; + const editor = this.editor; + const model = editor.model; + const imageUtils = editor.plugins.get( 'ImageUtils' ); model.change( writer => { const requestedArrangement = options.value; const supportedTypes = this._arrangements.get( requestedArrangement ).modelElements; - let imageElement = getClosestSelectedImageElement( model.document.selection ); + let imageElement = imageUtils.getClosestSelectedImageElement( model.document.selection ); // Change the image type if a style requires it. if ( !supportedTypes.includes( imageElement.name ) ) { this.editor.execute( !supportedTypes.includes( 'image' ) ? 'imageTypeInline' : 'imageTypeBlock' ); // Update the imageElement to the newly created image. - imageElement = getClosestSelectedImageElement( model.document.selection ); + imageElement = imageUtils.getClosestSelectedImageElement( model.document.selection ); } // Default style means that there is no `imageStyle` attribute in the model. diff --git a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativecommand.js b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativecommand.js index 9db14dcb8f8..2ad36464baf 100644 --- a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativecommand.js +++ b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativecommand.js @@ -8,7 +8,6 @@ */ import { Command } from 'ckeditor5/src/core'; -import { isImage, getClosestSelectedImageElement } from '../image/utils'; /** * The image text alternative command. It is used to change the `alt` attribute of `` and `` model elements. @@ -28,9 +27,11 @@ export default class ImageTextAlternativeCommand extends Command { * @inheritDoc */ refresh() { - const element = getClosestSelectedImageElement( this.editor.model.document.selection ); + const editor = this.editor; + const imageUtils = editor.plugins.get( 'ImageUtils' ); + const element = imageUtils.getClosestSelectedImageElement( this.editor.model.document.selection ); - this.isEnabled = isImage( element ); + this.isEnabled = !!element; if ( this.isEnabled && element.hasAttribute( 'alt' ) ) { this.value = element.getAttribute( 'alt' ); @@ -47,8 +48,10 @@ export default class ImageTextAlternativeCommand extends Command { * @param {String} options.newValue The new value of the `alt` attribute to set. */ execute( options ) { - const model = this.editor.model; - const imageElement = getClosestSelectedImageElement( model.document.selection ); + 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 ); diff --git a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeediting.js b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeediting.js index 907b1c5e925..d043dd4affd 100644 --- a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeediting.js +++ b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeediting.js @@ -7,8 +7,9 @@ * @module image/imagetextalternative/imagetextalternativeediting */ -import ImageTextAlternativeCommand from './imagetextalternativecommand'; import { Plugin } from 'ckeditor5/src/core'; +import ImageTextAlternativeCommand from './imagetextalternativecommand'; +import ImageUtils from '../imageutils'; /** * The image text alternative editing plugin. @@ -18,6 +19,13 @@ import { Plugin } from 'ckeditor5/src/core'; * @extends module:core/plugin~Plugin */ export default class ImageTextAlternativeEditing extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ ImageUtils ]; + } + /** * @inheritDoc */ diff --git a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.js b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.js index 3a56a656646..78918fa84cc 100644 --- a/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.js +++ b/packages/ckeditor5-image/src/imagetextalternative/imagetextalternativeui.js @@ -12,7 +12,6 @@ import { ButtonView, ContextualBalloon, clickOutsideHandler } from 'ckeditor5/sr import TextAlternativeFormView from './ui/textalternativeformview'; import { repositionContextualBalloon, getBalloonPositionData } from '../image/ui/utils'; -import { getClosestSelectedImageWidget } from '../image/utils'; /** * The image text alternative UI plugin. @@ -94,6 +93,7 @@ export default class ImageTextAlternativeUI extends Plugin { const editor = this.editor; const view = editor.editing.view; const viewDocument = view.document; + const imageUtils = editor.plugins.get( 'ImageUtils' ); /** * The contextual balloon plugin instance. @@ -133,7 +133,7 @@ export default class ImageTextAlternativeUI extends Plugin { // Reposition the balloon or hide the form if an image widget is no longer selected. this.listenTo( editor.ui, 'update', () => { - if ( !getClosestSelectedImageWidget( viewDocument.selection ) ) { + if ( !imageUtils.getClosestSelectedImageWidget( viewDocument.selection ) ) { this._hideForm( true ); } else if ( this._isVisible ) { repositionContextualBalloon( editor ); diff --git a/packages/ckeditor5-image/src/imagetoolbar.js b/packages/ckeditor5-image/src/imagetoolbar.js index d3db1c0f051..d326e9d4413 100644 --- a/packages/ckeditor5-image/src/imagetoolbar.js +++ b/packages/ckeditor5-image/src/imagetoolbar.js @@ -9,8 +9,7 @@ import { Plugin } from 'ckeditor5/src/core'; import { WidgetToolbarRepository } from 'ckeditor5/src/widget'; - -import { getClosestSelectedImageWidget } from './image/utils'; +import ImageUtils from './imageutils'; /** * The image toolbar plugin. It creates and manages the image toolbar (the toolbar displayed when an image is selected). @@ -30,7 +29,7 @@ export default class ImageToolbar extends Plugin { * @inheritDoc */ static get requires() { - return [ WidgetToolbarRepository ]; + return [ WidgetToolbarRepository, ImageUtils ]; } /** @@ -47,12 +46,13 @@ export default class ImageToolbar extends Plugin { const editor = this.editor; const t = editor.t; const widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository ); + const imageUtils = editor.plugins.get( 'ImageUtils' ); widgetToolbarRepository.register( 'image', { ariaLabel: t( 'Image toolbar' ), items: editor.config.get( 'image.toolbar' ) || [], // Get the selected image or an image containing the figcaption with the selection inside. - getRelatedElement: selection => getClosestSelectedImageWidget( selection ) + getRelatedElement: selection => imageUtils.getClosestSelectedImageWidget( selection ) } ); } } diff --git a/packages/ckeditor5-image/src/imageupload/imageuploadediting.js b/packages/ckeditor5-image/src/imageupload/imageuploadediting.js index 05ffa905fc1..b2bdbec5993 100644 --- a/packages/ckeditor5-image/src/imageupload/imageuploadediting.js +++ b/packages/ckeditor5-image/src/imageupload/imageuploadediting.js @@ -16,10 +16,10 @@ import { ClipboardPipeline } from 'ckeditor5/src/clipboard'; import { FileRepository } from 'ckeditor5/src/upload'; import { env } from 'ckeditor5/src/utils'; +import ImageUtils from '../imageutils'; import UploadImageCommand from './uploadimagecommand'; import { fetchLocalImage, isLocalImage } from '../../src/imageupload/utils'; import { createImageTypeRegExp } from './utils'; -import { getViewImageFromWidget, isImage } from '../image/utils'; /** * The editing part of the image upload feature. It registers the `'uploadImage'` command @@ -35,7 +35,7 @@ export default class ImageUploadEditing extends Plugin { * @inheritDoc */ static get requires() { - return [ FileRepository, Notification, ClipboardPipeline ]; + return [ FileRepository, Notification, ClipboardPipeline, ImageUtils ]; } static get pluginName() { @@ -64,6 +64,7 @@ export default class ImageUploadEditing extends Plugin { const schema = editor.model.schema; const conversion = editor.conversion; const fileRepository = editor.plugins.get( FileRepository ); + const imageUtils = editor.plugins.get( 'ImageUtils' ); const imageTypes = createImageTypeRegExp( editor.config.get( 'image.upload.types' ) ); @@ -141,7 +142,7 @@ export default class ImageUploadEditing extends Plugin { // (see Document#change listener below). this.listenTo( editor.plugins.get( 'ClipboardPipeline' ), 'inputTransformation', ( evt, data ) => { const fetchableImages = Array.from( editor.editing.view.createRangeIn( data.content ) ) - .filter( value => isLocalImage( value.item ) && !value.item.getAttribute( 'uploadProcessed' ) ) + .filter( value => isLocalImage( imageUtils, value.item ) && !value.item.getAttribute( 'uploadProcessed' ) ) .map( value => { return { promise: fetchLocalImage( value.item ), imageElement: value.item }; } ); if ( !fetchableImages.length ) { @@ -233,6 +234,7 @@ export default class ImageUploadEditing extends Plugin { const t = editor.locale.t; const fileRepository = editor.plugins.get( FileRepository ); const notification = editor.plugins.get( Notification ); + const imageUtils = editor.plugins.get( 'ImageUtils' ); model.enqueueChange( 'transparent', writer => { writer.setAttribute( 'uploadStatus', 'reading', imageElement ); @@ -247,7 +249,7 @@ export default class ImageUploadEditing extends Plugin { /* istanbul ignore next */ if ( env.isSafari ) { const viewFigure = editor.editing.mapper.toViewElement( imageElement ); - const viewImg = getViewImageFromWidget( viewFigure ); + const viewImg = imageUtils.getViewImageFromWidget( viewFigure ); editor.editing.view.once( 'render', () => { // Early returns just to be safe. There might be some code ran @@ -397,7 +399,9 @@ export function isHtmlIncluded( dataTransfer ) { } function getImagesFromChangeItem( editor, item ) { + const imageUtils = editor.plugins.get( 'ImageUtils' ); + return Array.from( editor.model.createRangeOn( item ) ) - .filter( value => isImage( value.item ) ) + .filter( value => imageUtils.isImage( value.item ) ) .map( value => value.item ); } diff --git a/packages/ckeditor5-image/src/imageupload/imageuploadprogress.js b/packages/ckeditor5-image/src/imageupload/imageuploadprogress.js index 1037a985c8e..bb018ec2515 100644 --- a/packages/ckeditor5-image/src/imageupload/imageuploadprogress.js +++ b/packages/ckeditor5-image/src/imageupload/imageuploadprogress.js @@ -11,7 +11,6 @@ import { Plugin } from 'ckeditor5/src/core'; import { FileRepository } from 'ckeditor5/src/upload'; -import { getViewImageFromWidget } from '../image/utils'; import uploadingPlaceholder from '../../theme/icons/image_placeholder.svg'; @@ -80,6 +79,7 @@ export default class ImageUploadProgress extends Plugin { return; } + const imageUtils = editor.plugins.get( 'ImageUtils' ); const fileRepository = editor.plugins.get( FileRepository ); const status = uploadId ? data.attributeNewValue : null; const placeholder = this.placeholder; @@ -90,7 +90,7 @@ export default class ImageUploadProgress extends Plugin { // Start "appearing" effect and show placeholder with infinite progress bar on the top // while image is read from disk. _startAppearEffect( viewFigure, viewWriter ); - _showPlaceholder( placeholder, viewFigure, viewWriter ); + _showPlaceholder( imageUtils, placeholder, viewFigure, viewWriter ); return; } @@ -106,12 +106,12 @@ export default class ImageUploadProgress extends Plugin { // There is no loader associated with uploadId - this means that image came from external changes. // In such cases we still want to show the placeholder until image is fully uploaded. // Show placeholder if needed - see https://github.com/ckeditor/ckeditor5-image/issues/191. - _showPlaceholder( placeholder, viewFigure, viewWriter ); + _showPlaceholder( imageUtils, placeholder, viewFigure, viewWriter ); } else { // Hide placeholder and initialize progress bar showing upload progress. _hidePlaceholder( viewFigure, viewWriter ); _showProgressBar( viewFigure, viewWriter, loader, editor.editing.view ); - _displayLocalImage( viewFigure, viewWriter, loader ); + _displayLocalImage( imageUtils, viewFigure, viewWriter, loader ); } return; @@ -148,15 +148,16 @@ function _stopAppearEffect( viewFigure, writer ) { // Shows placeholder together with infinite progress bar on given image figure. // +// @param {module:image/imageutils~ImageUtils} imageUtils // @param {String} Data-uri with a svg placeholder. // @param {module:engine/view/containerelement~ContainerElement} viewFigure // @param {module:engine/view/downcastwriter~DowncastWriter} writer -function _showPlaceholder( placeholder, viewFigure, writer ) { +function _showPlaceholder( imageUtils, placeholder, viewFigure, writer ) { if ( !viewFigure.hasClass( 'ck-image-upload-placeholder' ) ) { writer.addClass( 'ck-image-upload-placeholder', viewFigure ); } - const viewImg = getViewImageFromWidget( viewFigure ); + const viewImg = imageUtils.getViewImageFromWidget( viewFigure ); if ( viewImg.getAttribute( 'src' ) !== placeholder ) { writer.setAttribute( 'src', placeholder, viewImg ); @@ -278,12 +279,13 @@ function _removeUIElement( viewFigure, writer, uniqueProperty ) { // Displays local data from file loader. // +// @param {module:image/imageutils~ImageUtils} imageUtils // @param {module:engine/view/element~Element} imageFigure // @param {module:engine/view/downcastwriter~DowncastWriter} writer // @param {module:upload/filerepository~FileLoader} loader -function _displayLocalImage( viewFigure, writer, loader ) { +function _displayLocalImage( imageUtils, viewFigure, writer, loader ) { if ( loader.data ) { - const viewImg = getViewImageFromWidget( viewFigure ); + const viewImg = imageUtils.getViewImageFromWidget( viewFigure ); writer.setAttribute( 'src', loader.data, viewImg ); } diff --git a/packages/ckeditor5-image/src/imageupload/uploadimagecommand.js b/packages/ckeditor5-image/src/imageupload/uploadimagecommand.js index eb9c44c1435..c8f4aef3273 100644 --- a/packages/ckeditor5-image/src/imageupload/uploadimagecommand.js +++ b/packages/ckeditor5-image/src/imageupload/uploadimagecommand.js @@ -7,8 +7,6 @@ import { FileRepository } from 'ckeditor5/src/upload'; import { Command } from 'ckeditor5/src/core'; import { toArray } from 'ckeditor5/src/utils'; -import { insertImage, isImage, isImageAllowed } from '../image/utils'; - /** * @module image/imageupload/uploadimagecommand */ @@ -47,10 +45,12 @@ export default class UploadImageCommand extends Command { * @inheritDoc */ refresh() { - const selectedElement = this.editor.model.document.selection.getSelectedElement(); + const editor = this.editor; + const imageUtils = editor.plugins.get( 'ImageUtils' ); + const selectedElement = editor.model.document.selection.getSelectedElement(); // TODO: This needs refactoring. - this.isEnabled = isImageAllowed( this.editor ) || isImage( selectedElement ); + this.isEnabled = imageUtils.isImageAllowed() || imageUtils.isImage( selectedElement ); } /** @@ -63,37 +63,52 @@ export default class UploadImageCommand extends Command { execute( options ) { const files = toArray( options.file ); const selection = this.editor.model.document.selection; - const fileRepository = this.editor.plugins.get( FileRepository ); + const imageUtils = this.editor.plugins.get( 'ImageUtils' ); + + // In case of multiple files, each file (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() ); files.forEach( ( file, index ) => { const selectedElement = selection.getSelectedElement(); // 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 && isImage( selectedElement ) ) { + if ( index && selectedElement && imageUtils.isImage( selectedElement ) ) { const position = this.editor.model.createPositionAfter( selectedElement ); - uploadImage( this.editor, fileRepository, file, position ); + this._uploadImage( file, selectionAttributes, position ); } else { - uploadImage( this.editor, fileRepository, file ); + this._uploadImage( file, selectionAttributes ); } } ); } -} -// Handles uploading single file. -// -// @param {module:core/editor/editor~Editor} editor -// @param {module:upload/filerepository~FileRepository} fileRepository -// @param {File} file -// @param {module:engine/model/position~Position} position -function uploadImage( editor, fileRepository, file, position ) { - const loader = fileRepository.createLoader( file ); + /** + * Handles uploading single file. + * + * @private + * @param {File} file + * @param {Object} attributes + * @param {module:engine/model/position~Position} position + */ + _uploadImage( file, attributes, position ) { + const editor = this.editor; + const fileRepository = editor.plugins.get( FileRepository ); + const loader = fileRepository.createLoader( file ); + const imageUtils = editor.plugins.get( 'ImageUtils' ); + + // Do not throw when upload adapter is not set. FileRepository will log an error anyway. + if ( !loader ) { + return; + } - // Do not throw when upload adapter is not set. FileRepository will log an error anyway. - if ( !loader ) { - return; + imageUtils.insertImage( { ...attributes, uploadId: loader.id }, position ); } - - insertImage( editor, { uploadId: loader.id }, position ); } diff --git a/packages/ckeditor5-image/src/imageupload/utils.js b/packages/ckeditor5-image/src/imageupload/utils.js index 19cfac9f83a..2d24ff64f8d 100644 --- a/packages/ckeditor5-image/src/imageupload/utils.js +++ b/packages/ckeditor5-image/src/imageupload/utils.js @@ -10,7 +10,6 @@ /* global fetch, File */ import { global } from 'ckeditor5/src/utils'; -import { isInlineImageView } from '../image/utils'; /** * Creates a regular expression used to test for image files. @@ -65,11 +64,12 @@ export function fetchLocalImage( image ) { /** * Checks whether a given node is an image element with a local source (Base64 or blob). * + * @param {module:image/imageutils~ImageUtils} imageUtils * @param {module:engine/view/node~Node} node The node to check. * @returns {Boolean} */ -export function isLocalImage( node ) { - if ( !isInlineImageView( node ) || !node.getAttribute( 'src' ) ) { +export function isLocalImage( imageUtils, node ) { + if ( !imageUtils.isInlineImageView( node ) || !node.getAttribute( 'src' ) ) { return false; } diff --git a/packages/ckeditor5-image/src/imageutils.js b/packages/ckeditor5-image/src/imageutils.js new file mode 100644 index 00000000000..3f1a1607374 --- /dev/null +++ b/packages/ckeditor5-image/src/imageutils.js @@ -0,0 +1,345 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module image/imageutils + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { findOptimalInsertionRange, isWidget, toWidget } from 'ckeditor5/src/widget'; +import { determineImageTypeForInsertionAtSelection } from './image/utils'; + +/** + * A set of helpers related to images. + * + * @extends module:core/plugin~Plugin + */ +export default class ImageUtils extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'ImageUtils'; + } + + /** + * Checks if the provided model element is an `image` or `imageInline`. + * + * @param {module:engine/model/element~Element} modelElement + * @returns {Boolean} + */ + 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}. + * + * @param {module:engine/view/element~Element} element + * @returns {Boolean} + */ + 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}. + * + * @param {module:engine/view/element~Element} element + * @returns {Boolean} + */ + 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. + * + * insertImage( model, { src: 'path/to/image.jpg' } ); + * + * @param {Object} [attributes={}] Attributes of the inserted image. + * This method filters out the attributes which are disallowed by the {@link module:engine/model/schema~Schema}. + * @param {module:engine/model/selection~Selectable} [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 {'image'|'imageInline'} [imageType] Image type of inserted image. If not specified, + * it will be determined automatically depending of editor config or place of the insertion. + */ + insertImage( attributes = {}, selectable = null, imageType = null ) { + const editor = this.editor; + const model = editor.model; + const selection = model.document.selection; + + imageType = 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( imageType, attributeName ) ) { + delete attributes[ attributeName ]; + } + } + + model.change( writer => { + const imageElement = writer.createElement( imageType, attributes ); + + // 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). + if ( !selectable && imageType != 'imageInline' ) { + selectable = findOptimalInsertionRange( selection, model ); + } + + model.insertContent( imageElement, selectable ); + + // Inserting an image might've failed due to schema regulations. + if ( imageElement.parent ) { + writer.setSelection( imageElement, 'on' ); + } + } ); + } + + /** + * Returns an image widget editing view element if one is selected or is among the selection's ancestors. + * + * @protected + * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection + * @returns {module:engine/view/element~Element|null} + */ + getClosestSelectedImageWidget( selection ) { + const viewElement = selection.getSelectedElement(); + + if ( viewElement && this.isImageWidget( viewElement ) ) { + return viewElement; + } + + let parent = selection.getFirstPosition().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. + * + * @protected + * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection + * @returns {module:engine/model/element~Element|null} + */ + getClosestSelectedImageElement( selection ) { + const selectedElement = selection.getSelectedElement(); + + return this.isImage( selectedElement ) ? selectedElement : selection.getFirstPosition().findAncestor( 'image' ); + } + + /** + * Checks if image can be inserted at current model selection. + * + * @protected + * @returns {Boolean} + */ + 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. + * + * @protected + * @param {module:engine/view/element~Element} viewElement + * @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer. + * @param {String} label The element's label. It will be concatenated with the image `alt` attribute if one is present. + * @returns {module:engine/view/element~Element} + */ + toImageWidget( viewElement, writer, label ) { + writer.setCustomProperty( 'image', true, viewElement ); + + const labelCreator = () => { + const imgElement = this.getViewImageFromWidget( 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. + * + * @protected + * @param {module:engine/view/element~Element} viewElement + * @returns {Boolean} + */ + isImageWidget( viewElement ) { + return !!viewElement.getCustomProperty( 'image' ) && isWidget( viewElement ); + } + + /** + * Checks if the provided model element is an `image`. + * + * @protected + * @param {module:engine/model/element~Element} modelElement + * @returns {Boolean} + */ + isBlockImage( modelElement ) { + return !!modelElement && modelElement.is( 'element', 'image' ); + } + + /** + * Checks if the provided model element is an `imageInline`. + * + * @protected + * @param {module:engine/model/element~Element} modelElement + * @returns {Boolean} + */ + isInlineImage( modelElement ) { + return !!modelElement && modelElement.is( 'element', 'imageInline' ); + } + + /** + * Get view `` element from the view widget (`
`). + * + * Assuming that image is always a first child of a widget (ie. `figureView.getChild( 0 )`) is unsafe as other features might + * inject their own elements to the widget. + * + * The `` can be wrapped to other elements, e.g. ``. Nested check required. + * + * @protected + * @param {module:engine/view/element~Element} figureView + * @returns {module:engine/view/element~Element} + */ + getViewImageFromWidget( figureView ) { + if ( this.isInlineImageView( figureView ) ) { + return figureView; + } + + const figureChildren = []; + + for ( const figureChild of figureView.getChildren() ) { + figureChildren.push( figureChild ); + + if ( figureChild.is( 'element' ) ) { + figureChildren.push( ...figureChild.getChildren() ); + } + } + + return figureChildren.find( this.isInlineImageView ); + } +} + +// Checks if image is allowed by schema in optimal insertion parent. +// +// @private +// @param {module:core/editor/editor~Editor} editor +// @param {module:engine/model/selection~Selection} selection +// @returns {Boolean} +function isImageAllowedInParent( editor, selection ) { + const imageType = determineImageTypeForInsertion( editor, selection ); + + if ( imageType == 'image' ) { + const parent = getInsertImageParent( selection, editor.model ); + + if ( editor.model.schema.checkChild( parent, 'image' ) ) { + 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). +// +// @private +// @param {module:engine/model/selection~Selectable} selection +// @returns {Boolean} +function isNotInsideImage( selection ) { + return [ ...selection.focus.getAncestors() ].every( ancestor => !ancestor.is( 'element', 'image' ) ); +} + +// Returns a node that will be used to insert image with `model.insertContent`. +// +// @private +// @param {module:engine/model/selection~Selection} selection +// @param {module:engine/model/model~Model} model +// @returns {module:engine/model/element~Element} +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. +// +// @private +// @param {module:core/editor/editor~Editor} editor +// @param {module:engine/model/selection~Selectable} selectable +// @param {'image'|'imageInline'} [imageType] Image element type name. Used to force return of provided element name, +// but only if there is proper plugin enabled. +// @returns {'image'|'imageInline'} imageType +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 'image'; + } + + if ( imageType ) { + return imageType; + } + + if ( configImageInsertType === 'inline' ) { + return 'imageInline'; + } + + if ( configImageInsertType === 'block' ) { + return 'image'; + } + + // Try to replace the selected widget (e.g. another image). + if ( selectable.is( 'selection' ) ) { + return determineImageTypeForInsertionAtSelection( schema, selectable ); + } + + return schema.checkChild( selectable, 'imageInline' ) ? 'imageInline' : 'image'; +} diff --git a/packages/ckeditor5-image/tests/autoimage.js b/packages/ckeditor5-image/tests/autoimage.js index be69511658f..f885c60782a 100644 --- a/packages/ckeditor5-image/tests/autoimage.js +++ b/packages/ckeditor5-image/tests/autoimage.js @@ -9,6 +9,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import Link from '@ckeditor/ckeditor5-link/src/link'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage'; import Table from '@ckeditor/ckeditor5-table/src/table'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; import Undo from '@ckeditor/ckeditor5-undo/src/undo'; @@ -16,6 +17,7 @@ import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import Image from '../src/image'; +import ImageUtils from '../src/imageutils'; import ImageCaption from '../src/imagecaption'; import AutoImage from '../src/autoimage'; @@ -28,7 +30,7 @@ describe( 'AutoImage - integration', () => { return ClassicTestEditor .create( editorElement, { - plugins: [ Typing, Paragraph, Link, Image, ImageCaption, AutoImage ] + plugins: [ Typing, Paragraph, Link, Image, LinkImage, ImageCaption, AutoImage ] } ) .then( newEditor => { editor = newEditor; @@ -41,12 +43,16 @@ describe( 'AutoImage - integration', () => { return editor.destroy(); } ); - it( 'should load Clipboard plugin', () => { - expect( editor.plugins.get( Clipboard ) ).to.instanceOf( Clipboard ); + it( 'should load the Clipboard plugin', () => { + expect( AutoImage.requires ).to.include( Clipboard ); } ); - it( 'should load Undo plugin', () => { - expect( editor.plugins.get( Undo ) ).to.instanceOf( Undo ); + it( 'should load the Undo plugin', () => { + expect( AutoImage.requires ).to.include( Undo ); + } ); + + it( 'should load the ImageUtils plugin', () => { + expect( AutoImage.requires ).to.include( ImageUtils ); } ); it( 'has proper name', () => { @@ -332,6 +338,21 @@ describe( 'AutoImage - integration', () => { 'Bar.' ); } ); + + it( 'should insert an image into a link and preserve its continuity (LinkImage integration)', () => { + setData( editor.model, '<$text linkHref="https://cksource.com">linked[]text' ); + pasteHtml( editor, 'http://example.com/image.png' ); + + clock.tick( 100 ); + + expect( getData( editor.model ) ).to.equal( + '' + + '<$text linkHref="https://cksource.com">linked' + + '[]' + + '<$text linkHref="https://cksource.com">text' + + '' + ); + } ); } ); describe( 'use real timers', () => { diff --git a/packages/ckeditor5-image/tests/image.js b/packages/ckeditor5-image/tests/image.js index 95317801235..4fd54355dcc 100644 --- a/packages/ckeditor5-image/tests/image.js +++ b/packages/ckeditor5-image/tests/image.js @@ -138,16 +138,4 @@ describe( 'Image', () => { ); } ); } ); - - describe( 'isImageWidget()', () => { - it( 'should expose isImageWidget() utility', () => { - expect( editor.plugins.get( 'Image' ) ).to.respondTo( 'isImageWidget' ); - } ); - - it( 'should return true for elements marked with toImageWidget()', () => { - setModelData( model, '[alt text]' ); - const element = viewDocument.getRoot().getChild( 0 ); - expect( editor.plugins.get( 'Image' ).isImageWidget( element ) ).to.be.true; - } ); - } ); } ); diff --git a/packages/ckeditor5-image/tests/image/converters.js b/packages/ckeditor5-image/tests/image/converters.js index 6caff4c34e4..3a3e62eee59 100644 --- a/packages/ckeditor5-image/tests/image/converters.js +++ b/packages/ckeditor5-image/tests/image/converters.js @@ -3,11 +3,12 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +import ImageEditing from '../../src/image/imageediting'; import { viewFigureToModel, modelToViewAttributeConverter } from '../../src/image/converters'; -import { toImageWidget, createImageViewElement } from '../../src/image/utils'; +import { createImageViewElement } from '../../src/image/utils'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; @@ -20,51 +21,53 @@ describe( 'Image converters', () => { testUtils.createSinonSandbox(); beforeEach( () => { - return VirtualTestEditor.create() - .then( newEditor => { - editor = newEditor; - model = editor.model; - document = model.document; - viewDocument = editor.editing.view; - - const schema = model.schema; - - schema.register( 'image', { - allowWhere: '$block', - allowAttributes: [ 'alt', 'src' ], - isObject: true, - isBlock: true - } ); - - schema.register( 'imageInline', { - allowWhere: '$inline', - allowAttributes: [ 'alt', 'src' ], - isObject: true, - isInline: true - } ); + return VirtualTestEditor.create( { + plugins: [ ImageEditing ] + } ).then( newEditor => { + editor = newEditor; + model = editor.model; + document = model.document; + viewDocument = editor.editing.view; + + const imageUtils = editor.plugins.get( 'ImageUtils' ); + const schema = model.schema; + + schema.register( 'image', { + allowWhere: '$block', + allowAttributes: [ 'alt', 'src' ], + isObject: true, + isBlock: true + } ); - const imageEditingElementCreator = ( modelElement, { writer } ) => - toImageWidget( createImageViewElement( writer, 'image' ), writer, '' ); + schema.register( 'imageInline', { + allowWhere: '$inline', + allowAttributes: [ 'alt', 'src' ], + isObject: true, + isInline: true + } ); - const imageInlineEditingElementCreator = ( modelElement, { writer } ) => - toImageWidget( createImageViewElement( writer, 'imageInline' ), writer, '' ); + const imageEditingElementCreator = ( modelElement, { writer } ) => + imageUtils.toImageWidget( createImageViewElement( writer, 'image' ), writer, '' ); - editor.conversion.for( 'editingDowncast' ).elementToElement( { - model: 'image', - view: imageEditingElementCreator - } ); + const imageInlineEditingElementCreator = ( modelElement, { writer } ) => + imageUtils.toImageWidget( createImageViewElement( writer, 'imageInline' ), writer, '' ); - editor.conversion.for( 'editingDowncast' ).elementToElement( { - model: 'imageInline', - view: imageInlineEditingElementCreator - } ); + editor.conversion.for( 'editingDowncast' ).elementToElement( { + model: 'image', + view: imageEditingElementCreator + } ); - editor.conversion.for( 'downcast' ) - .add( modelToViewAttributeConverter( 'image', 'src' ) ) - .add( modelToViewAttributeConverter( 'imageInline', 'src' ) ) - .add( modelToViewAttributeConverter( 'image', 'alt' ) ) - .add( modelToViewAttributeConverter( 'imageInline', 'alt' ) ); + editor.conversion.for( 'editingDowncast' ).elementToElement( { + model: 'imageInline', + view: imageInlineEditingElementCreator } ); + + editor.conversion.for( 'downcast' ) + .add( modelToViewAttributeConverter( imageUtils, 'image', 'src' ) ) + .add( modelToViewAttributeConverter( imageUtils, 'imageInline', 'src' ) ) + .add( modelToViewAttributeConverter( imageUtils, 'image', 'alt' ) ) + .add( modelToViewAttributeConverter( imageUtils, 'imageInline', 'alt' ) ); + } ); } ); describe( 'viewFigureToModel', () => { @@ -83,7 +86,7 @@ describe( 'Image converters', () => { schema.extend( '$text', { allowIn: 'image' } ); editor.conversion.for( 'upcast' ) - .add( viewFigureToModel() ) + .add( viewFigureToModel( editor.plugins.get( 'ImageUtils' ) ) ) .elementToElement( { view: { name: 'img', diff --git a/packages/ckeditor5-image/tests/image/imageblockediting.js b/packages/ckeditor5-image/tests/image/imageblockediting.js index 272a9f8928f..23df47093ee 100644 --- a/packages/ckeditor5-image/tests/image/imageblockediting.js +++ b/packages/ckeditor5-image/tests/image/imageblockediting.js @@ -22,7 +22,6 @@ import ImageTypeCommand from '../../src/image/imagetypecommand'; import InsertImageCommand from '../../src/image/insertimagecommand'; import ImageCaption from '../../src/imagecaption'; import ImageLoadObserver from '../../src/image/imageloadobserver'; -import { isImageWidget } from '../../src/image/utils'; describe( 'ImageBlockEditing', () => { let editor, model, doc, view, viewDocument; @@ -479,7 +478,7 @@ describe( 'ImageBlockEditing', () => { const figure = viewDocument.getRoot().getChild( 0 ); expect( figure.name ).to.equal( 'figure' ); - expect( isImageWidget( figure ) ).to.be.true; + expect( editor.plugins.get( 'ImageUtils' ).isImageWidget( figure ) ).to.be.true; } ); it( 'should convert attribute change', () => { diff --git a/packages/ckeditor5-image/tests/image/imageediting.js b/packages/ckeditor5-image/tests/image/imageediting.js index 9b4c38f73a7..ade77215b77 100644 --- a/packages/ckeditor5-image/tests/image/imageediting.js +++ b/packages/ckeditor5-image/tests/image/imageediting.js @@ -19,7 +19,6 @@ import InsertImageCommand from '../../src/image/insertimagecommand'; import ImageTypeCommand from '../../src/image/imagetypecommand'; import ImageBlockEditing from '../../src/image/imageblockediting'; import ImageInlineEditing from '../../src/image/imageinlineediting'; -import { isImageWidget } from '../../src/image/utils'; describe( 'ImageEditing', () => { let editor, model, doc, view, viewDocument; @@ -733,13 +732,13 @@ describe( 'ImageEditing', () => { const figure = viewDocument.getRoot().getChild( 0 ); expect( figure.name ).to.equal( 'figure' ); - expect( isImageWidget( figure ) ).to.be.true; + expect( editor.plugins.get( 'ImageUtils' ).isImageWidget( figure ) ).to.be.true; setModelData( model, '' ); const element = viewDocument.getRoot().getChild( 0 ).getChild( 0 ); expect( element.name ).to.equal( 'span' ); - expect( isImageWidget( element ) ).to.be.true; + expect( editor.plugins.get( 'ImageUtils' ).isImageWidget( element ) ).to.be.true; } ); it( 'should convert attribute change', () => { diff --git a/packages/ckeditor5-image/tests/image/imageinlineediting.js b/packages/ckeditor5-image/tests/image/imageinlineediting.js index 0c3e32eee70..fae0f176ed7 100644 --- a/packages/ckeditor5-image/tests/image/imageinlineediting.js +++ b/packages/ckeditor5-image/tests/image/imageinlineediting.js @@ -10,6 +10,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import DataTransfer from '@ckeditor/ckeditor5-clipboard/src/datatransfer'; import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml'; @@ -22,7 +23,6 @@ import InsertImageCommand from '../../src/image/insertimagecommand'; import ImageCaption from '../../src/imagecaption'; import ImageLoadObserver from '../../src/image/imageloadobserver'; import ImageInlineEditing from '../../src/image/imageinlineediting'; -import { isImageWidget } from '../../src/image/utils'; describe( 'ImageInlineEditing', () => { let editor, model, doc, view, viewDocument; @@ -464,7 +464,7 @@ describe( 'ImageInlineEditing', () => { const element = viewDocument.getRoot().getChild( 0 ).getChild( 0 ); expect( element.name ).to.equal( 'span' ); - expect( isImageWidget( element ) ).to.be.true; + expect( editor.plugins.get( 'ImageUtils' ).isImageWidget( element ) ).to.be.true; } ); it( 'should convert attribute change', () => { @@ -641,7 +641,7 @@ describe( 'ImageInlineEditing', () => { document.body.appendChild( editorElement ); editor = await ClassicTestEditor.create( editorElement, { - plugins: [ ImageInlineEditing, ImageBlockEditing, ImageCaption, Clipboard, Paragraph ] + plugins: [ ImageInlineEditing, ImageBlockEditing, ImageCaption, Clipboard, LinkImage, Paragraph ] } ); model = editor.model; @@ -774,5 +774,20 @@ describe( 'ImageInlineEditing', () => { 'f[]oo' ); } ); + + it( 'should preserve image link when converting to an inline image (LinkImage integration)', () => { + const dataTransfer = new DataTransfer( { + types: [ 'text/html' ], + getData: () => '
' + } ); + + setModelData( model, 'f[]oo' ); + + viewDocument.fire( 'clipboardInput', { dataTransfer } ); + + expect( getModelData( model ) ).to.equal( + 'f[]oo' + ); + } ); } ); } ); diff --git a/packages/ckeditor5-image/tests/image/insertimagecommand.js b/packages/ckeditor5-image/tests/image/insertimagecommand.js index 4c33e532e0a..329630ac8ac 100644 --- a/packages/ckeditor5-image/tests/image/insertimagecommand.js +++ b/packages/ckeditor5-image/tests/image/insertimagecommand.js @@ -249,5 +249,27 @@ describe( 'InsertImageCommand', () => { 'foo[]bar' ); } ); + + it( 'should set document selection attributes on an image to maintain their continuity in downcast (e.g. links)', () => { + editor.model.schema.extend( '$text', { allowAttributes: [ 'foo', 'bar', 'baz' ] } ); + + editor.model.schema.extend( 'imageInline', { + allowAttributes: [ 'foo', 'bar' ] + } ); + + const imgSrc = 'foo/bar.jpg'; + + setModelData( model, '<$text bar="b" baz="c" foo="a">f[o]o' ); + + command.execute( { source: imgSrc } ); + + expect( getModelData( model ) ).to.equal( + '' + + '<$text bar="b" baz="c" foo="a">f' + + '[]' + + '<$text bar="b" baz="c" foo="a">o' + + '' + ); + } ); } ); } ); diff --git a/packages/ckeditor5-image/tests/image/utils.js b/packages/ckeditor5-image/tests/image/utils.js index b99fd34d818..d325ff71309 100644 --- a/packages/ckeditor5-image/tests/image/utils.js +++ b/packages/ckeditor5-image/tests/image/utils.js @@ -3,18 +3,16 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* global console, document */ +/* global document */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter'; import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter'; import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document'; -import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap'; -import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; -import { parse as parseView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; -import { isWidget, getLabel } from '@ckeditor/ckeditor5-widget/src/utils'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { parse as parseView, stringify as stringifyView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; import Table from '@ckeditor/ckeditor5-table/src/table'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; @@ -23,635 +21,34 @@ import Image from '../../src/image'; import ImageEditing from '../../src/image/imageediting'; import ImageBlockEditing from '../../src/image/imageblockediting'; import ImageInlineEditing from '../../src/image/imageinlineediting'; -import ImageCaptionEditing from '../../src/imagecaption/imagecaptionediting'; +import ImageUtils from '../../src/imageutils'; import { - toImageWidget, - isImageWidget, - getClosestSelectedImageWidget, - isImage, - isInlineImage, - isBlockImage, - isImageAllowed, - insertImage, - getViewImageFromWidget, - isInlineImageView, - isBlockImageView, - determineImageTypeForInsertionAtSelection, getImageTypeMatcher, - getClosestSelectedImageElement + createImageViewElement, + determineImageTypeForInsertionAtSelection } from '../../src/image/utils'; -describe( 'image widget utils', () => { - let element, image, writer, viewDocument; +describe( 'image utils', () => { + let editor, imageUtils, element, image, writer, viewDocument; + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ ImageUtils ] + } ); + + imageUtils = editor.plugins.get( 'ImageUtils' ); - beforeEach( () => { viewDocument = new ViewDocument( new StylesProcessor() ); writer = new ViewDowncastWriter( viewDocument ); image = writer.createContainerElement( 'img' ); element = writer.createContainerElement( 'figure' ); writer.insert( writer.createPositionAt( element, 0 ), image ); - toImageWidget( element, writer, 'image widget' ); + imageUtils.toImageWidget( element, writer, 'image widget' ); } ); - describe( 'toImageWidget()', () => { - it( 'should be widgetized', () => { - expect( isWidget( element ) ).to.be.true; - } ); - - it( 'should set element\'s label', () => { - expect( getLabel( element ) ).to.equal( 'image widget' ); - } ); - - it( 'should set element\'s label combined with alt attribute', () => { - writer.setAttribute( 'alt', 'foo bar baz', image ); - expect( getLabel( element ) ).to.equal( 'foo bar baz image widget' ); - } ); - - it( 'provided label creator should always return same label', () => { - writer.setAttribute( 'alt', 'foo bar baz', image ); - - expect( getLabel( element ) ).to.equal( 'foo bar baz image widget' ); - expect( getLabel( element ) ).to.equal( 'foo bar baz image widget' ); - } ); - } ); - - describe( 'isImageWidget()', () => { - it( 'should return true for elements marked with toImageWidget()', () => { - expect( isImageWidget( element ) ).to.be.true; - } ); - - it( 'should return false for non-widgetized elements', () => { - expect( isImageWidget( writer.createContainerElement( 'p' ) ) ).to.be.false; - } ); - } ); - - describe( 'getClosestSelectedImageWidget()', () => { - let frag; - - it( 'should return an image widget when it is the only element in the selection', () => { - // We need to create a container for the element to be able to create a Range on this element. - frag = writer.createDocumentFragment( element ); - - const selection = writer.createSelection( element, 'on' ); - - expect( getClosestSelectedImageWidget( selection ) ).to.equal( element ); - } ); - - describe( 'when the selection is inside a block image caption', () => { - let caption; - - beforeEach( () => { - caption = writer.createContainerElement( 'figcaption' ); - writer.insert( writer.createPositionAt( element, 1 ), caption ); - frag = writer.createDocumentFragment( element ); - } ); - - it( 'should return the widget element if the selection is not collapsed', () => { - const text = writer.createText( 'foo' ); - writer.insert( writer.createPositionAt( caption, 0 ), text ); - - const selection = writer.createSelection( writer.createRangeIn( caption ) ); - - expect( getClosestSelectedImageWidget( selection ) ).to.equal( element ); - } ); - - it( 'should return the widget element if the selection is collapsed', () => { - const selection = writer.createSelection( caption, 'in' ); - - expect( getClosestSelectedImageWidget( selection ) ).to.equal( element ); - } ); - } ); - - it( 'should return null when non-widgetized elements is the only element in the selection', () => { - const notWidgetizedElement = writer.createContainerElement( 'p' ); - - // We need to create a container for the element to be able to create a Range on this element. - frag = writer.createDocumentFragment( notWidgetizedElement ); - - const selection = writer.createSelection( notWidgetizedElement, 'on' ); - - expect( getClosestSelectedImageWidget( selection ) ).to.be.null; - } ); - - it( 'should return null when widget element is not the only element in the selection', () => { - const notWidgetizedElement = writer.createContainerElement( 'p' ); - - frag = writer.createDocumentFragment( [ element, notWidgetizedElement ] ); - - const selection = writer.createSelection( writer.createRangeIn( frag ) ); - - expect( getClosestSelectedImageWidget( selection ) ).to.be.null; - } ); - - it( 'should return null if an image is a part of the selection', () => { - const notWidgetizedElement = writer.createContainerElement( 'p' ); - - frag = writer.createDocumentFragment( [ element, notWidgetizedElement ] ); - - const selection = writer.createSelection( writer.createRangeIn( frag ) ); - - expect( getClosestSelectedImageWidget( selection ) ).to.be.null; - } ); - - it( 'should return null if the selection is inside a figure element, which is not an image', () => { - const innerContainer = writer.createContainerElement( 'p' ); - - element = writer.createContainerElement( 'figure' ); - - writer.insert( writer.createPositionAt( element, 1 ), innerContainer ); - - frag = writer.createDocumentFragment( element ); - - const selection = writer.createSelection( innerContainer, 'in' ); - - expect( getClosestSelectedImageWidget( selection ) ).to.be.null; - } ); - } ); - - describe( 'isImage()', () => { - it( 'should return true for the block image element', () => { - const image = new ModelElement( 'image' ); - - expect( isImage( image ) ).to.be.true; - } ); - - it( 'should return true for the inline image element', () => { - const image = new ModelElement( 'imageInline' ); - - expect( isImage( image ) ).to.be.true; - } ); - - it( 'should return false for different elements', () => { - const image = new ModelElement( 'foo' ); - - expect( isImage( image ) ).to.be.false; - } ); - - it( 'should return false for null and undefined', () => { - expect( isImage( null ) ).to.be.false; - expect( isImage( undefined ) ).to.be.false; - } ); - } ); - - describe( 'isInlineImage()', () => { - it( 'should return true for the inline image element', () => { - const image = new ModelElement( 'imageInline' ); - - expect( isInlineImage( image ) ).to.be.true; - } ); - - it( 'should return false for the block image element', () => { - const image = new ModelElement( 'image' ); - - expect( isInlineImage( image ) ).to.be.false; - } ); - - it( 'should return false for different elements', () => { - const image = new ModelElement( 'foo' ); - - expect( isInlineImage( image ) ).to.be.false; - } ); - - it( 'should return false for null and undefined', () => { - expect( isInlineImage( null ) ).to.be.false; - expect( isInlineImage( undefined ) ).to.be.false; - } ); - } ); - - describe( 'isBlockImage()', () => { - it( 'should return false for the inline image element', () => { - const image = new ModelElement( 'imageInline' ); - - expect( isBlockImage( image ) ).to.be.false; - } ); - - it( 'should return true for the block image element', () => { - const image = new ModelElement( 'image' ); - - expect( isBlockImage( image ) ).to.be.true; - } ); - - it( 'should return false for different elements', () => { - const image = new ModelElement( 'foo' ); - - expect( isBlockImage( image ) ).to.be.false; - } ); - - it( 'should return false for null and undefined', () => { - expect( isBlockImage( null ) ).to.be.false; - expect( isBlockImage( undefined ) ).to.be.false; - } ); - } ); - - describe( 'isInlineImageView()', () => { - it( 'should return false for the block image element', () => { - const element = writer.createContainerElement( 'figure', { class: 'image' } ); - - expect( isInlineImageView( element ) ).to.be.false; - } ); - - it( 'should return true for the inline view image element', () => { - const element = writer.createEmptyElement( 'img' ); - - expect( isInlineImageView( element ) ).to.be.true; - } ); - - it( 'should return false for other view element', () => { - const element = writer.createContainerElement( 'div' ); - - expect( isInlineImageView( element ) ).to.be.false; - } ); - - it( 'should return false for null, undefined', () => { - expect( isInlineImageView() ).to.be.false; - expect( isInlineImageView( null ) ).to.be.false; - } ); - } ); - - describe( 'isBlockImageView()', () => { - it( 'should return false for the inline image element', () => { - const element = writer.createEmptyElement( 'img' ); - - expect( isBlockImageView( element ) ).to.be.false; - } ); - - it( 'should return true for the block view image element', () => { - const element = writer.createContainerElement( 'figure', { class: 'image' } ); - - expect( isBlockImageView( element ) ).to.be.true; - } ); - - it( 'should return false for the figure without a proper class', () => { - const element = writer.createContainerElement( 'figure' ); - - expect( isBlockImageView( element ) ).to.be.false; - } ); - - it( 'should return false for the non-figure with a proper class', () => { - const element = writer.createContainerElement( 'div', { class: 'image' } ); - - expect( isBlockImageView( element ) ).to.be.false; - } ); - - it( 'should return false for other view element', () => { - const element = writer.createContainerElement( 'div' ); - - expect( isBlockImageView( element ) ).to.be.false; - } ); - - it( 'should return false for null, undefined', () => { - expect( isBlockImageView() ).to.be.false; - expect( isBlockImageView( null ) ).to.be.false; - } ); - } ); - - describe( 'isImageAllowed()', () => { - let editor, model; - - beforeEach( () => { - return VirtualTestEditor - .create( { - plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph ] - } ) - .then( newEditor => { - editor = newEditor; - model = editor.model; - - const schema = model.schema; - schema.extend( 'image', { allowAttributes: 'uploadId' } ); - } ); - } ); - - it( 'should return true when the selection directly in the root', () => { - model.enqueueChange( 'transparent', () => { - setModelData( model, '[]' ); - - expect( isImageAllowed( editor ) ).to.be.true; - } ); - } ); - - it( 'should return true when the selection is in empty block', () => { - setModelData( model, '[]' ); - - expect( isImageAllowed( editor ) ).to.be.true; - } ); - - it( 'should return true when the selection directly in a paragraph', () => { - setModelData( model, 'foo[]' ); - expect( isImageAllowed( editor ) ).to.be.true; - } ); - - it( 'should return true when the selection directly in a block', () => { - model.schema.register( 'block', { inheritAllFrom: '$block' } ); - model.schema.extend( '$text', { allowIn: 'block' } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); - - setModelData( model, 'foo[]' ); - expect( isImageAllowed( editor ) ).to.be.true; - } ); - - it( 'should return true when the selection is on other image', () => { - setModelData( model, '[]' ); - expect( isImageAllowed( editor ) ).to.be.true; - } ); - - it( 'should return false when the selection is inside other image', () => { - model.schema.register( 'caption', { - allowIn: 'image', - allowContentOf: '$block', - isLimit: true - } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'caption', view: 'figcaption' } ); - setModelData( model, '[]' ); - expect( isImageAllowed( editor ) ).to.be.false; - } ); - - it( 'should return true when the selection is on other object', () => { - model.schema.register( 'object', { isObject: true, allowIn: '$root' } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); - setModelData( model, '[]' ); - - expect( isImageAllowed( editor ) ).to.be.true; - } ); - - it( 'should be true when the selection is inside isLimit element which allows image', () => { - model.schema.register( 'table', { allowWhere: '$block', isLimit: true, isObject: true, isBlock: true } ); - model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } ); - model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } ); - model.schema.extend( '$block', { allowIn: 'tableCell' } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'table', view: 'table' } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableRow', view: 'tableRow' } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableCell', view: 'tableCell' } ); - - setModelData( model, 'foo[]
' ); - - expect( isImageAllowed( editor ) ).to.be.true; - } ); - - it( 'should return false when schema disallows image', () => { - model.schema.register( 'block', { inheritAllFrom: '$block' } ); - model.schema.extend( 'paragraph', { allowIn: 'block' } ); - // Block image in block. - model.schema.addChildCheck( ( context, childDefinition ) => { - if ( childDefinition.name === 'image' && context.last.name === 'block' ) { - return false; - } - if ( childDefinition.name === 'imageInline' && context.last.name === 'paragraph' ) { - return false; - } - } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); - - setModelData( model, '[]' ); - - expect( isImageAllowed( editor ) ).to.be.false; - } ); - } ); - - describe( 'insertImage()', () => { - let editor, model; - - beforeEach( () => { - return VirtualTestEditor - .create( { - plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph ] - } ) - .then( newEditor => { - editor = newEditor; - model = editor.model; - - const schema = model.schema; - schema.extend( 'image', { allowAttributes: 'uploadId' } ); - } ); - } ); - - it( 'should insert inline image in a paragraph with text', () => { - setModelData( model, 'f[o]o' ); - - insertImage( editor ); - - expect( getModelData( model ) ).to.equal( 'f[]o' ); - } ); - - it( 'should insert a block image when the selection is inside an empty paragraph', () => { - setModelData( model, '[]' ); - - insertImage( editor ); - - expect( getModelData( model ) ).to.equal( '[]' ); - } ); - - it( 'should insert a block image in the document root', () => { - setModelData( model, '[]' ); - - insertImage( editor ); - - expect( getModelData( model ) ).to.equal( '[]' ); - } ); - - it( 'should insert image with given attributes', () => { - setModelData( model, 'f[o]o' ); - - insertImage( editor, { src: 'bar' } ); - - expect( getModelData( model ) ).to.equal( 'f[]o' ); - } ); - - it( 'should not insert image nor crash when image could not be inserted', () => { - model.schema.register( 'other', { - allowIn: '$root', - allowChildren: '$text', - isLimit: true - } ); - - editor.conversion.for( 'downcast' ).elementToElement( { model: 'other', view: 'p' } ); - - setModelData( model, '[]' ); - - insertImage( editor ); - - expect( getModelData( model ) ).to.equal( '[]' ); - } ); - - it( 'should use the block image type when the config.image.insert.type="block" option is set', async () => { - const newEditor = await VirtualTestEditor.create( { - plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph ], - image: { insert: { type: 'block' } } - } ); - - setModelData( newEditor.model, 'f[o]o' ); - - insertImage( newEditor ); - - expect( getModelData( newEditor.model ) ).to.equal( '[]foo' ); - - await newEditor.destroy(); - } ); - - it( 'should use the inline image type if the config.image.insert.type="inline" option is set', async () => { - const newEditor = await VirtualTestEditor.create( { - plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph ], - image: { insert: { type: 'inline' } } - } ); - - setModelData( newEditor.model, 'f[o]o' ); - - insertImage( newEditor ); - - expect( getModelData( newEditor.model ) ).to.equal( 'f[]o' ); - - await newEditor.destroy(); - } ); - - it( 'should use the inline image type when there is only ImageInlineEditing plugin enabled', async () => { - const newEditor = await VirtualTestEditor.create( { - plugins: [ ImageInlineEditing, Paragraph ] - } ); - - setModelData( newEditor.model, 'f[o]o' ); - - insertImage( newEditor ); - - expect( getModelData( newEditor.model ) ).to.equal( 'f[]o' ); - - await newEditor.destroy(); - } ); - - it( 'should use block the image type when there is only ImageBlockEditing plugin enabled', async () => { - const newEditor = await VirtualTestEditor.create( { - plugins: [ ImageBlockEditing, Paragraph ] - } ); - - setModelData( newEditor.model, 'f[o]o' ); - - insertImage( newEditor ); - - expect( getModelData( newEditor.model ) ).to.equal( '[]foo' ); - - await newEditor.destroy(); - } ); - - it( 'should use the block image type when the config.image.insert.type="inline" option is set ' + - 'but ImageInlineEditing plugin is not enabled', async () => { - const consoleWarnStub = sinon.stub( console, 'warn' ); - const newEditor = await VirtualTestEditor.create( { - plugins: [ ImageBlockEditing, Paragraph ], - image: { insert: { type: 'inline' } } - } ); - - setModelData( newEditor.model, 'f[o]o' ); - - insertImage( newEditor ); - - expect( consoleWarnStub.calledOnce ).to.equal( true ); - expect( consoleWarnStub.firstCall.args[ 0 ] ).to.equal( 'image-inline-plugin-required' ); - expect( getModelData( newEditor.model ) ).to.equal( '[]foo' ); - - await newEditor.destroy(); - console.warn.restore(); - } ); - - it( 'should use the inline image type when the image.insert.type="block" option is set ' + - 'but ImageBlockEditing plugin is not enabled', async () => { - const consoleWarnStub = sinon.stub( console, 'warn' ); - const newEditor = await VirtualTestEditor.create( { - plugins: [ ImageInlineEditing, Paragraph ], - image: { insert: { type: 'block' } } - } ); - - setModelData( newEditor.model, 'f[o]o' ); - - insertImage( newEditor ); - - expect( consoleWarnStub.calledOnce ).to.equal( true ); - expect( consoleWarnStub.firstCall.args[ 0 ] ).to.equal( 'image-block-plugin-required' ); - expect( getModelData( newEditor.model ) ).to.equal( 'f[]o' ); - - await newEditor.destroy(); - console.warn.restore(); - } ); - - it( 'should pass the allowed custom attributes to the inserted block image', () => { - setModelData( model, '[]' ); - model.schema.extend( 'image', { allowAttributes: 'customAttribute' } ); - - insertImage( editor, { src: 'foo', customAttribute: 'value' } ); - - expect( getModelData( model ) ) - .to.equal( '[]' ); - } ); - - it( 'should omit the disallowed attributes while inserting a block image', () => { - setModelData( model, '[]' ); - - insertImage( editor, { src: 'foo', customAttribute: 'value' } ); - - expect( getModelData( model ) ) - .to.equal( '[]' ); - } ); - - it( 'should pass the allowed custom attributes to the inserted inline image', () => { - setModelData( model, 'f[o]o' ); - model.schema.extend( 'imageInline', { allowAttributes: 'customAttribute' } ); - - insertImage( editor, { src: 'foo', customAttribute: 'value' } ); - - expect( getModelData( model ) ) - .to.equal( 'f[]o' ); - } ); - - it( 'should omit the disallowed attributes while inserting an inline image', () => { - setModelData( model, 'f[o]o' ); - - insertImage( editor, { src: 'foo', customAttribute: 'value' } ); - - expect( getModelData( model ) ).to.equal( 'f[]o' ); - } ); - } ); - - describe( 'getViewImageFromWidget()', () => { - // figure - // img - it( 'returns the the img element from widget if the img is the first children', () => { - expect( getViewImageFromWidget( element ) ).to.equal( image ); - } ); - - // figure - // div - // img - it( 'returns the the img element from widget if the img is not the first children', () => { - writer.insert( writer.createPositionAt( element, 0 ), writer.createContainerElement( 'div' ) ); - expect( getViewImageFromWidget( element ) ).to.equal( image ); - } ); - - // figure - // div - // img - it( 'returns the the img element from widget if the img is a child of another element', () => { - const divElement = writer.createContainerElement( 'div' ); - - writer.insert( writer.createPositionAt( element, 0 ), divElement ); - writer.move( writer.createRangeOn( image ), writer.createPositionAt( divElement, 0 ) ); - - expect( getViewImageFromWidget( element ) ).to.equal( image ); - } ); - - // figure - // div - // "Bar" - // img - // "Foo" - it( 'does not throw an error if text node found', () => { - const divElement = writer.createContainerElement( 'div' ); - - writer.insert( writer.createPositionAt( element, 0 ), divElement ); - writer.insert( writer.createPositionAt( element, 0 ), writer.createText( 'Foo' ) ); - writer.insert( writer.createPositionAt( divElement, 0 ), writer.createText( 'Bar' ) ); - writer.move( writer.createRangeOn( image ), writer.createPositionAt( divElement, 1 ) ); - - expect( getViewImageFromWidget( element ) ).to.equal( image ); - } ); + afterEach( async () => { + return editor.destroy(); } ); describe( 'determineImageTypeForInsertionAtSelection()', () => { @@ -659,9 +56,10 @@ describe( 'image widget utils', () => { beforeEach( async () => { editor = await VirtualTestEditor.create( { - plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph ] + plugins: [ ImageUtils, ImageBlockEditing, ImageInlineEditing, Paragraph ] } ); + imageUtils = editor.plugins.get( 'ImageUtils' ); model = editor.model; schema = model.schema; schema.register( 'block', { @@ -683,6 +81,10 @@ describe( 'image widget utils', () => { editor.conversion.for( 'downcast' ).elementToElement( { model: 'inlineWidget', view: 'inlineWidget' } ); } ); + afterEach( async () => { + return editor.destroy(); + } ); + it( 'should return "image" when there is no selected block in the selection', () => { setModelData( model, 'f[]oo' ); @@ -719,8 +121,10 @@ describe( 'image widget utils', () => { beforeEach( async () => { editor = await VirtualTestEditor.create( { - plugins: [ ImageEditing ] + plugins: [ ImageUtils, ImageEditing ] } ); + + imageUtils = editor.plugins.get( 'ImageUtils' ); } ); afterEach( async () => { @@ -738,15 +142,15 @@ describe( 'image widget utils', () => { it( 'should return a matcher pattern for an img element if ImageBlockEditing plugin is not loaded', () => { sinon.stub( editor.plugins, 'has' ).callsFake( pluginName => pluginName !== 'ImageBlockEditing' ); - expect( getImageTypeMatcher( 'image', editor ) ).to.eql( returnValue ); - expect( getImageTypeMatcher( 'imageInline', editor ) ).to.eql( returnValue ); + expect( getImageTypeMatcher( editor, 'image' ) ).to.eql( returnValue ); + expect( getImageTypeMatcher( editor, 'imageInline' ) ).to.eql( returnValue ); } ); it( 'should return a matcher patter for an img element if ImageInlineEditing plugin is not loaded', () => { sinon.stub( editor.plugins, 'has' ).callsFake( pluginName => pluginName !== 'ImageInlineEditing' ); - expect( getImageTypeMatcher( 'image', editor ) ).to.eql( returnValue ); - expect( getImageTypeMatcher( 'imageInline', editor ) ).to.eql( returnValue ); + expect( getImageTypeMatcher( editor, 'image', editor ) ).to.eql( returnValue ); + expect( getImageTypeMatcher( editor, 'imageInline' ) ).to.eql( returnValue ); } ); } ); @@ -758,9 +162,11 @@ describe( 'image widget utils', () => { document.body.appendChild( editorElement ); editor = await ClassicTestEditor.create( editorElement, { - plugins: [ Image, Paragraph, Table ] + plugins: [ ImageUtils, Image, Paragraph, Table ] } ); + imageUtils = editor.plugins.get( 'ImageUtils' ); + writer = new UpcastWriter( editor.editing.view.document ); } ); @@ -772,7 +178,7 @@ describe( 'image widget utils', () => { describe( 'the returned matcherPattern function', () => { describe( 'for the "image" type requested', () => { beforeEach( () => { - matcherPattern = getImageTypeMatcher( 'image', editor ); + matcherPattern = getImageTypeMatcher( editor, 'image' ); } ); it( 'should return a function', () => { @@ -820,7 +226,7 @@ describe( 'image widget utils', () => { describe( 'for the "imageInline" type requested', () => { beforeEach( () => { - matcherPattern = getImageTypeMatcher( 'imageInline', editor ); + matcherPattern = getImageTypeMatcher( editor, 'imageInline' ); } ); it( 'should return a function', () => { @@ -870,66 +276,44 @@ describe( 'image widget utils', () => { } ); } ); - describe( 'getClosestSelectedImageElement()', () => { - let model; - - beforeEach( async () => { - const editor = await VirtualTestEditor.create( { - plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph, ImageCaptionEditing ] - } ); - - model = editor.model; - - model.schema.register( 'blockWidget', { - isObject: true, - allowIn: '$root' - } ); - - editor.conversion.for( 'downcast' ).elementToElement( { model: 'blockWidget', view: 'blockWidget' } ); - } ); - - it( 'should return null if no element is selected and the selection has no image ancestor', () => { - setModelData( model, 'F[]oo' ); - - expect( getClosestSelectedImageElement( model.document.selection ) ).to.be.null; - } ); + describe( 'createImageViewElement()', () => { + let writer; - it( 'should return null if a non-image element is selected', () => { - setModelData( model, '[]' ); - - expect( getClosestSelectedImageElement( model.document.selection ) ).to.be.null; - } ); - - it( 'should return an imageInline element if it is selected', () => { - setModelData( model, '[]' ); - - const image = getClosestSelectedImageElement( model.document.selection ); - - expect( image.is( 'element', 'imageInline' ) ).to.be.true; + beforeEach( () => { + const document = new ViewDocument( new StylesProcessor() ); + writer = new ViewDowncastWriter( document ); } ); - it( 'should return an image element if it is selected', () => { - setModelData( model, '[]' ); + it( 'should create a figure element for "image" type', () => { + const element = createImageViewElement( writer, 'image' ); - const image = getClosestSelectedImageElement( model.document.selection ); - - expect( image.is( 'element', 'image' ) ).to.be.true; + expect( element.is( 'element', 'figure' ) ).to.be.true; + expect( element.hasClass( 'image' ) ).to.be.true; + expect( element.childCount ).to.equal( 1 ); + expect( element.getChild( 0 ).is( 'emptyElement', 'img' ) ).to.be.true; } ); - it( 'should return an image element if the selection range is inside its caption', () => { - setModelData( model, 'F[oo]' ); - - const image = getClosestSelectedImageElement( model.document.selection ); + it( 'should create a span element for "imageInline" type', () => { + const element = createImageViewElement( writer, 'imageInline' ); - expect( image.is( 'element', 'image' ) ).to.be.true; + expect( element.is( 'element', 'span' ) ).to.be.true; + expect( element.hasClass( 'image-inline' ) ).to.be.true; + expect( element.childCount ).to.equal( 1 ); + expect( element.getChild( 0 ).is( 'emptyElement', 'img' ) ).to.be.true; } ); - it( 'should return an image element if the selection position is inside its caption', () => { - setModelData( model, 'Foo[]' ); + it( 'should create a span element for "imageInline" type that does not break the parent attribute element', () => { + const paragraph = writer.createContainerElement( 'p' ); + const imageElement = createImageViewElement( writer, 'imageInline' ); + const attributeElement = writer.createAttributeElement( 'a', { foo: 'bar' } ); - const image = getClosestSelectedImageElement( model.document.selection ); + writer.insert( writer.createPositionAt( paragraph, 0 ), imageElement ); + writer.insert( writer.createPositionAt( paragraph, 0 ), writer.createText( 'foo' ) ); + writer.wrap( writer.createRangeIn( paragraph ), attributeElement ); - expect( image.is( 'element', 'image' ) ).to.be.true; + expect( stringifyView( paragraph ) ).to.equal( + '

foo

' + ); } ); } ); } ); diff --git a/packages/ckeditor5-image/tests/imagecaption/utils.js b/packages/ckeditor5-image/tests/imagecaption/utils.js index 2a4c5f53657..1499f1a629e 100644 --- a/packages/ckeditor5-image/tests/imagecaption/utils.js +++ b/packages/ckeditor5-image/tests/imagecaption/utils.js @@ -3,22 +3,33 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import View from '@ckeditor/ckeditor5-engine/src/view/view'; +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; + +import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element'; + +import ImageCaptionEditing from '../../src/imagecaption/imagecaptionediting'; import { getCaptionFromImageModelElement, matchImageCaptionViewElement } from '../../src/imagecaption/utils'; -import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; describe( 'image captioning utils', () => { - let view, document; + let editor, view, document; - beforeEach( () => { - view = new View(); + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ ImageCaptionEditing ] + } ); + + view = editor.editing.view; document = view.document; } ); + afterEach( async () => { + return editor.destroy(); + } ); + describe( 'getCaptionFromImageModelElement', () => { it( 'should return caption element from image element', () => { const dummy = new ModelElement( 'dummy' ); @@ -39,34 +50,34 @@ describe( 'image captioning utils', () => { it( 'should return null for element that is not a figcaption', () => { const element = new ViewElement( document, 'div' ); - expect( matchImageCaptionViewElement( element ) ).to.be.null; + expect( matchImageCaptionViewElement( editor.plugins.get( 'ImageUtils' ), element ) ).to.be.null; } ); it( 'should return null if figcaption has no parent', () => { const element = new ViewElement( document, 'figcaption' ); - expect( matchImageCaptionViewElement( element ) ).to.be.null; + expect( matchImageCaptionViewElement( editor.plugins.get( 'ImageUtils' ), element ) ).to.be.null; } ); it( 'should return null if figcaption\'s parent is not a figure', () => { const element = new ViewElement( document, 'figcaption' ); new ViewElement( document, 'div', null, element ); // eslint-disable-line no-new - expect( matchImageCaptionViewElement( element ) ).to.be.null; + expect( matchImageCaptionViewElement( editor.plugins.get( 'ImageUtils' ), element ) ).to.be.null; } ); it( 'should return null if parent has no image class', () => { const element = new ViewElement( document, 'figcaption' ); new ViewElement( document, 'figure', null, element ); // eslint-disable-line no-new - expect( matchImageCaptionViewElement( element ) ).to.be.null; + expect( matchImageCaptionViewElement( editor.plugins.get( 'ImageUtils' ), element ) ).to.be.null; } ); it( 'should return object if element is a valid caption', () => { const element = new ViewElement( document, 'figcaption' ); new ViewElement( document, 'figure', { class: 'image' }, element ); // eslint-disable-line no-new - expect( matchImageCaptionViewElement( element ) ).to.deep.equal( { name: true } ); + expect( matchImageCaptionViewElement( editor.plugins.get( 'ImageUtils' ), element ) ).to.deep.equal( { name: true } ); } ); } ); } ); diff --git a/packages/ckeditor5-image/tests/imageresize/resizeimagecommand.js b/packages/ckeditor5-image/tests/imageresize/resizeimagecommand.js index d956cfb5f87..683e7cb5c8e 100644 --- a/packages/ckeditor5-image/tests/imageresize/resizeimagecommand.js +++ b/packages/ckeditor5-image/tests/imageresize/resizeimagecommand.js @@ -5,32 +5,37 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; import ResizeImageCommand from '../../src/imageresize/resizeimagecommand'; +import ImageResizeEditing from '../../src/imageresize/imageresizeediting'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; describe( 'ResizeImageCommand', () => { - let model, command; - - beforeEach( () => { - return ModelTestEditor.create() - .then( newEditor => { - model = newEditor.model; - command = new ResizeImageCommand( newEditor ); - - model.schema.register( 'p', { inheritAllFrom: '$block' } ); - - model.schema.register( 'image', { - isObject: true, - isBlock: true, - allowWhere: '$block', - allowAttributes: 'width' - } ); - - model.schema.register( 'caption', { - allowContentOf: '$block', - allowIn: 'image', - isLimit: true - } ); - } ); + let editor, model, command; + + beforeEach( async () => { + editor = await ModelTestEditor.create( { + plugins: [ ImageResizeEditing ] + } ); + model = editor.model; + command = new ResizeImageCommand( editor ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + + model.schema.register( 'image', { + isObject: true, + isBlock: true, + allowWhere: '$block', + allowAttributes: 'width' + } ); + + model.schema.register( 'caption', { + allowContentOf: '$block', + allowIn: 'image', + isLimit: true + } ); + } ); + + afterEach( async () => { + return editor.destroy(); } ); describe( '#isEnabled', () => { diff --git a/packages/ckeditor5-image/tests/imagetextalternative/imagetextalternativecommand.js b/packages/ckeditor5-image/tests/imagetextalternative/imagetextalternativecommand.js index d8c010b4d19..da5ea52e977 100644 --- a/packages/ckeditor5-image/tests/imagetextalternative/imagetextalternativecommand.js +++ b/packages/ckeditor5-image/tests/imagetextalternative/imagetextalternativecommand.js @@ -6,38 +6,43 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; import ImageTextAlternativeCommand from '../../src/imagetextalternative/imagetextalternativecommand'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import ImageTextAlternativeEditing from '../../src/imagetextalternative/imagetextalternativeediting'; describe( 'ImageTextAlternativeCommand', () => { - let model, command; + let editor, model, command; - beforeEach( () => { - return ModelTestEditor.create() - .then( newEditor => { - model = newEditor.model; - command = new ImageTextAlternativeCommand( newEditor ); + beforeEach( async () => { + editor = await ModelTestEditor.create( { + plugins: [ ImageTextAlternativeEditing ] + } ); + model = editor.model; + command = new ImageTextAlternativeCommand( editor ); - model.schema.register( 'p', { inheritAllFrom: '$block' } ); + model.schema.register( 'p', { inheritAllFrom: '$block' } ); - model.schema.register( 'image', { - allowWhere: '$block', - isObject: true, - isBlock: true, - alllowAttributes: [ 'alt', 'src' ] - } ); + model.schema.register( 'image', { + allowWhere: '$block', + isObject: true, + isBlock: true, + alllowAttributes: [ 'alt', 'src' ] + } ); - model.schema.register( 'imageInline', { - allowWhere: '$text', - isObject: true, - isInline: true, - allowAttributes: [ 'alt', 'src', 'srcset' ] - } ); + model.schema.register( 'imageInline', { + allowWhere: '$text', + isObject: true, + isInline: true, + allowAttributes: [ 'alt', 'src', 'srcset' ] + } ); - model.schema.register( 'caption', { - allowContentOf: '$block', - allowIn: 'image', - isLimit: true - } ); - } ); + model.schema.register( 'caption', { + allowContentOf: '$block', + allowIn: 'image', + isLimit: true + } ); + } ); + + afterEach( async () => { + return editor.destroy(); } ); it( 'should have false value if no image is selected', () => { diff --git a/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js b/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js index 53532ded7e2..9cbc3f8bde3 100644 --- a/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js +++ b/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js @@ -740,7 +740,7 @@ describe( 'ImageUploadEditing', () => { editor.model.schema.extend( 'image', { allowAttributes: 'data-original' } ); editor.conversion.for( 'downcast' ) - .add( modelToViewAttributeConverter( 'data-original' ) ); + .add( modelToViewAttributeConverter( editor.plugins.get( 'ImageUtils' ), 'data-original' ) ); editor.conversion.for( 'upcast' ) .attributeToAttribute( { diff --git a/packages/ckeditor5-image/tests/imageupload/uploadimagecommand.js b/packages/ckeditor5-image/tests/imageupload/uploadimagecommand.js index ced649e7125..ffe383a97a9 100644 --- a/packages/ckeditor5-image/tests/imageupload/uploadimagecommand.js +++ b/packages/ckeditor5-image/tests/imageupload/uploadimagecommand.js @@ -152,6 +152,25 @@ describe( 'UploadImageCommand', () => { .to.equal( `f[]o` ); } ); + it( 'should insert multiple images at selection position, one after another', () => { + const file = [ createNativeFileMock(), createNativeFileMock(), createNativeFileMock() ]; + setModelData( model, 'f[o]o' ); + + command.execute( { file } ); + + const idA = fileRepository.getLoader( file[ 0 ] ).id; + const idB = fileRepository.getLoader( file[ 1 ] ).id; + const idC = fileRepository.getLoader( file[ 2 ] ).id; + + expect( getModelData( model ) ).to.equal( + 'f' + + `` + + `` + + `[]` + + 'o' + ); + } ); + it( 'should use parent batch', () => { const file = createNativeFileMock(); @@ -200,5 +219,55 @@ describe( 'UploadImageCommand', () => { expect( getModelData( model ) ).to.equal( 'fo[]o' ); sinon.assert.calledOnce( consoleWarnStub ); } ); + + it( 'should set document selection attributes on an image to maintain attribute continuity in downcast (e.g. links)', () => { + editor.model.schema.extend( '$text', { allowAttributes: [ 'foo', 'bar', 'baz' ] } ); + + editor.model.schema.extend( 'imageInline', { + allowAttributes: [ 'foo', 'bar' ] + } ); + + const file = createNativeFileMock(); + setModelData( model, '<$text bar="b" baz="c" foo="a">f[o]o' ); + + command.execute( { file } ); + + const id = fileRepository.getLoader( file ).id; + + expect( getModelData( model ) ).to.equal( + '' + + '<$text bar="b" baz="c" foo="a">f' + + `[]` + + '<$text bar="b" baz="c" foo="a">o' + + '' + ); + } ); + + it( 'should set document selection attributes on multiple images to maintain attribute continuity in downcast (e.g. links)', () => { + editor.model.schema.extend( '$text', { allowAttributes: [ 'foo', 'bar', 'baz' ] } ); + + editor.model.schema.extend( 'imageInline', { + allowAttributes: [ 'foo', 'bar' ] + } ); + + const file = [ createNativeFileMock(), createNativeFileMock(), createNativeFileMock() ]; + setModelData( model, '<$text bar="b" baz="c" foo="a">f[o]o' ); + + command.execute( { file } ); + + const idA = fileRepository.getLoader( file[ 0 ] ).id; + const idB = fileRepository.getLoader( file[ 1 ] ).id; + const idC = fileRepository.getLoader( file[ 2 ] ).id; + + expect( getModelData( model ) ).to.equal( + '' + + '<$text bar="b" baz="c" foo="a">f' + + `` + + `` + + `[]` + + '<$text bar="b" baz="c" foo="a">o' + + '' + ); + } ); } ); } ); diff --git a/packages/ckeditor5-image/tests/imageutils.js b/packages/ckeditor5-image/tests/imageutils.js new file mode 100644 index 00000000000..0069b65bff8 --- /dev/null +++ b/packages/ckeditor5-image/tests/imageutils.js @@ -0,0 +1,719 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global console */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter'; +import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document'; +import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; +import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap'; +import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { isWidget, getLabel } from '@ckeditor/ckeditor5-widget/src/utils'; + +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import ImageBlockEditing from '../src/image/imageblockediting'; +import ImageInlineEditing from '../src/image/imageinlineediting'; +import ImageCaptionEditing from '../src/imagecaption/imagecaptionediting'; + +import ImageUtils from '../src/imageutils'; + +describe( 'ImageUtils plugin', () => { + let editor, imageUtils, element, image, writer, viewDocument; + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ ImageUtils ] + } ); + + imageUtils = editor.plugins.get( 'ImageUtils' ); + + viewDocument = new ViewDocument( new StylesProcessor() ); + writer = new ViewDowncastWriter( viewDocument ); + image = writer.createContainerElement( 'img' ); + element = writer.createContainerElement( 'figure' ); + writer.insert( writer.createPositionAt( element, 0 ), image ); + imageUtils.toImageWidget( element, writer, 'image widget' ); + } ); + + afterEach( async () => { + return editor.destroy(); + } ); + + it( 'should have a name', () => { + expect( ImageUtils.pluginName ).to.equal( 'ImageUtils' ); + } ); + + describe( 'toImageWidget()', () => { + it( 'should be widgetized', () => { + expect( isWidget( element ) ).to.be.true; + } ); + + it( 'should set element\'s label', () => { + expect( getLabel( element ) ).to.equal( 'image widget' ); + } ); + + it( 'should set element\'s label combined with alt attribute', () => { + writer.setAttribute( 'alt', 'foo bar baz', image ); + expect( getLabel( element ) ).to.equal( 'foo bar baz image widget' ); + } ); + + it( 'provided label creator should always return same label', () => { + writer.setAttribute( 'alt', 'foo bar baz', image ); + + expect( getLabel( element ) ).to.equal( 'foo bar baz image widget' ); + expect( getLabel( element ) ).to.equal( 'foo bar baz image widget' ); + } ); + } ); + + describe( 'isImageWidget()', () => { + it( 'should return true for elements marked with toImageWidget()', () => { + expect( imageUtils.isImageWidget( element ) ).to.be.true; + } ); + + it( 'should return false for non-widgetized elements', () => { + expect( imageUtils.isImageWidget( writer.createContainerElement( 'p' ) ) ).to.be.false; + } ); + } ); + + describe( 'getClosestSelectedImageWidget()', () => { + let frag; + + it( 'should return an image widget when it is the only element in the selection', () => { + // We need to create a container for the element to be able to create a Range on this element. + frag = writer.createDocumentFragment( element ); + + const selection = writer.createSelection( element, 'on' ); + + expect( imageUtils.getClosestSelectedImageWidget( selection ) ).to.equal( element ); + } ); + + describe( 'when the selection is inside a block image caption', () => { + let caption; + + beforeEach( () => { + caption = writer.createContainerElement( 'figcaption' ); + writer.insert( writer.createPositionAt( element, 1 ), caption ); + frag = writer.createDocumentFragment( element ); + } ); + + it( 'should return the widget element if the selection is not collapsed', () => { + const text = writer.createText( 'foo' ); + writer.insert( writer.createPositionAt( caption, 0 ), text ); + + const selection = writer.createSelection( writer.createRangeIn( caption ) ); + + expect( imageUtils.getClosestSelectedImageWidget( selection ) ).to.equal( element ); + } ); + + it( 'should return the widget element if the selection is collapsed', () => { + const selection = writer.createSelection( caption, 'in' ); + + expect( imageUtils.getClosestSelectedImageWidget( selection ) ).to.equal( element ); + } ); + } ); + + it( 'should return null when non-widgetized elements is the only element in the selection', () => { + const notWidgetizedElement = writer.createContainerElement( 'p' ); + + // We need to create a container for the element to be able to create a Range on this element. + frag = writer.createDocumentFragment( notWidgetizedElement ); + + const selection = writer.createSelection( notWidgetizedElement, 'on' ); + + expect( imageUtils.getClosestSelectedImageWidget( selection ) ).to.be.null; + } ); + + it( 'should return null when widget element is not the only element in the selection', () => { + const notWidgetizedElement = writer.createContainerElement( 'p' ); + + frag = writer.createDocumentFragment( [ element, notWidgetizedElement ] ); + + const selection = writer.createSelection( writer.createRangeIn( frag ) ); + + expect( imageUtils.getClosestSelectedImageWidget( selection ) ).to.be.null; + } ); + + it( 'should return null if an image is a part of the selection', () => { + const notWidgetizedElement = writer.createContainerElement( 'p' ); + + frag = writer.createDocumentFragment( [ element, notWidgetizedElement ] ); + + const selection = writer.createSelection( writer.createRangeIn( frag ) ); + + expect( imageUtils.getClosestSelectedImageWidget( selection ) ).to.be.null; + } ); + + it( 'should return null if the selection is inside a figure element, which is not an image', () => { + const innerContainer = writer.createContainerElement( 'p' ); + + element = writer.createContainerElement( 'figure' ); + + writer.insert( writer.createPositionAt( element, 1 ), innerContainer ); + + frag = writer.createDocumentFragment( element ); + + const selection = writer.createSelection( innerContainer, 'in' ); + + expect( imageUtils.getClosestSelectedImageWidget( selection ) ).to.be.null; + } ); + } ); + + describe( 'getClosestSelectedImageElement()', () => { + let model; + + beforeEach( async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph, ImageCaptionEditing ] + } ); + + model = editor.model; + + model.schema.register( 'blockWidget', { + isObject: true, + allowIn: '$root' + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'blockWidget', view: 'blockWidget' } ); + } ); + + it( 'should return null if no element is selected and the selection has no image ancestor', () => { + setModelData( model, 'F[]oo' ); + + expect( imageUtils.getClosestSelectedImageElement( model.document.selection ) ).to.be.null; + } ); + + it( 'should return null if a non-image element is selected', () => { + setModelData( model, '[]' ); + + expect( imageUtils.getClosestSelectedImageElement( model.document.selection ) ).to.be.null; + } ); + + it( 'should return an imageInline element if it is selected', () => { + setModelData( model, '[]' ); + + const image = imageUtils.getClosestSelectedImageElement( model.document.selection ); + + expect( image.is( 'element', 'imageInline' ) ).to.be.true; + } ); + + it( 'should return an image element if it is selected', () => { + setModelData( model, '[]' ); + + const image = imageUtils.getClosestSelectedImageElement( model.document.selection ); + + expect( image.is( 'element', 'image' ) ).to.be.true; + } ); + + it( 'should return an image element if the selection range is inside its caption', () => { + setModelData( model, 'F[oo]' ); + + const image = imageUtils.getClosestSelectedImageElement( model.document.selection ); + + expect( image.is( 'element', 'image' ) ).to.be.true; + } ); + + it( 'should return an image element if the selection position is inside its caption', () => { + setModelData( model, 'Foo[]' ); + + const image = imageUtils.getClosestSelectedImageElement( model.document.selection ); + + expect( image.is( 'element', 'image' ) ).to.be.true; + } ); + } ); + + describe( 'isImage()', () => { + it( 'should return true for the block image element', () => { + const image = new ModelElement( 'image' ); + + expect( imageUtils.isImage( image ) ).to.be.true; + } ); + + it( 'should return true for the inline image element', () => { + const image = new ModelElement( 'imageInline' ); + + expect( imageUtils.isImage( image ) ).to.be.true; + } ); + + it( 'should return false for different elements', () => { + const image = new ModelElement( 'foo' ); + + expect( imageUtils.isImage( image ) ).to.be.false; + } ); + + it( 'should return false for null and undefined', () => { + expect( imageUtils.isImage( null ) ).to.be.false; + expect( imageUtils.isImage( undefined ) ).to.be.false; + } ); + } ); + + describe( 'isInlineImage()', () => { + it( 'should return true for the inline image element', () => { + const image = new ModelElement( 'imageInline' ); + + expect( imageUtils.isInlineImage( image ) ).to.be.true; + } ); + + it( 'should return false for the block image element', () => { + const image = new ModelElement( 'image' ); + + expect( imageUtils.isInlineImage( image ) ).to.be.false; + } ); + + it( 'should return false for different elements', () => { + const image = new ModelElement( 'foo' ); + + expect( imageUtils.isInlineImage( image ) ).to.be.false; + } ); + + it( 'should return false for null and undefined', () => { + expect( imageUtils.isInlineImage( null ) ).to.be.false; + expect( imageUtils.isInlineImage( undefined ) ).to.be.false; + } ); + } ); + + describe( 'isBlockImage()', () => { + it( 'should return false for the inline image element', () => { + const image = new ModelElement( 'imageInline' ); + + expect( imageUtils.isBlockImage( image ) ).to.be.false; + } ); + + it( 'should return true for the block image element', () => { + const image = new ModelElement( 'image' ); + + expect( imageUtils.isBlockImage( image ) ).to.be.true; + } ); + + it( 'should return false for different elements', () => { + const image = new ModelElement( 'foo' ); + + expect( imageUtils.isBlockImage( image ) ).to.be.false; + } ); + + it( 'should return false for null and undefined', () => { + expect( imageUtils.isBlockImage( null ) ).to.be.false; + expect( imageUtils.isBlockImage( undefined ) ).to.be.false; + } ); + } ); + + describe( 'isInlineImageView()', () => { + it( 'should return false for the block image element', () => { + const element = writer.createContainerElement( 'figure', { class: 'image' } ); + + expect( imageUtils.isInlineImageView( element ) ).to.be.false; + } ); + + it( 'should return true for the inline view image element', () => { + const element = writer.createEmptyElement( 'img' ); + + expect( imageUtils.isInlineImageView( element ) ).to.be.true; + } ); + + it( 'should return false for other view element', () => { + const element = writer.createContainerElement( 'div' ); + + expect( imageUtils.isInlineImageView( element ) ).to.be.false; + } ); + + it( 'should return false for null, undefined', () => { + expect( imageUtils.isInlineImageView() ).to.be.false; + expect( imageUtils.isInlineImageView( null ) ).to.be.false; + } ); + } ); + + describe( 'isBlockImageView()', () => { + it( 'should return false for the inline image element', () => { + const element = writer.createEmptyElement( 'img' ); + + expect( imageUtils.isBlockImageView( element ) ).to.be.false; + } ); + + it( 'should return true for the block view image element', () => { + const element = writer.createContainerElement( 'figure', { class: 'image' } ); + + expect( imageUtils.isBlockImageView( element ) ).to.be.true; + } ); + + it( 'should return false for the figure without a proper class', () => { + const element = writer.createContainerElement( 'figure' ); + + expect( imageUtils.isBlockImageView( element ) ).to.be.false; + } ); + + it( 'should return false for the non-figure with a proper class', () => { + const element = writer.createContainerElement( 'div', { class: 'image' } ); + + expect( imageUtils.isBlockImageView( element ) ).to.be.false; + } ); + + it( 'should return false for other view element', () => { + const element = writer.createContainerElement( 'div' ); + + expect( imageUtils.isBlockImageView( element ) ).to.be.false; + } ); + + it( 'should return false for null, undefined', () => { + expect( imageUtils.isBlockImageView() ).to.be.false; + expect( imageUtils.isBlockImageView( null ) ).to.be.false; + } ); + } ); + + describe( 'isImageAllowed()', () => { + let editor, model; + + beforeEach( () => { + return VirtualTestEditor + .create( { + plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + imageUtils = editor.plugins.get( 'ImageUtils' ); + + const schema = model.schema; + schema.extend( 'image', { allowAttributes: 'uploadId' } ); + } ); + } ); + + it( 'should return true when the selection directly in the root', () => { + model.enqueueChange( 'transparent', () => { + setModelData( model, '[]' ); + + expect( imageUtils.isImageAllowed() ).to.be.true; + } ); + } ); + + it( 'should return true when the selection is in empty block', () => { + setModelData( model, '[]' ); + + expect( imageUtils.isImageAllowed() ).to.be.true; + } ); + + it( 'should return true when the selection directly in a paragraph', () => { + setModelData( model, 'foo[]' ); + expect( imageUtils.isImageAllowed() ).to.be.true; + } ); + + it( 'should return true when the selection directly in a block', () => { + model.schema.register( 'block', { inheritAllFrom: '$block' } ); + model.schema.extend( '$text', { allowIn: 'block' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); + + setModelData( model, 'foo[]' ); + expect( imageUtils.isImageAllowed() ).to.be.true; + } ); + + it( 'should return true when the selection is on other image', () => { + setModelData( model, '[]' ); + expect( imageUtils.isImageAllowed() ).to.be.true; + } ); + + it( 'should return false when the selection is inside other image', () => { + model.schema.register( 'caption', { + allowIn: 'image', + allowContentOf: '$block', + isLimit: true + } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'caption', view: 'figcaption' } ); + setModelData( model, '[]' ); + expect( imageUtils.isImageAllowed() ).to.be.false; + } ); + + it( 'should return true when the selection is on other object', () => { + model.schema.register( 'object', { isObject: true, allowIn: '$root' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + setModelData( model, '[]' ); + + expect( imageUtils.isImageAllowed() ).to.be.true; + } ); + + it( 'should be true when the selection is inside isLimit element which allows image', () => { + model.schema.register( 'table', { allowWhere: '$block', isLimit: true, isObject: true, isBlock: true } ); + model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } ); + model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } ); + model.schema.extend( '$block', { allowIn: 'tableCell' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'table', view: 'table' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableRow', view: 'tableRow' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableCell', view: 'tableCell' } ); + + setModelData( model, 'foo[]
' ); + + expect( imageUtils.isImageAllowed() ).to.be.true; + } ); + + it( 'should return false when schema disallows image', () => { + model.schema.register( 'block', { inheritAllFrom: '$block' } ); + model.schema.extend( 'paragraph', { allowIn: 'block' } ); + // Block image in block. + model.schema.addChildCheck( ( context, childDefinition ) => { + if ( childDefinition.name === 'image' && context.last.name === 'block' ) { + return false; + } + if ( childDefinition.name === 'imageInline' && context.last.name === 'paragraph' ) { + return false; + } + } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); + + setModelData( model, '[]' ); + + expect( imageUtils.isImageAllowed() ).to.be.false; + } ); + } ); + + describe( 'insertImage()', () => { + let editor, model; + + beforeEach( () => { + return VirtualTestEditor + .create( { + plugins: [ ImageBlockEditing, ImageInlineEditing, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + + imageUtils = editor.plugins.get( 'ImageUtils' ); + + const schema = model.schema; + schema.extend( 'image', { allowAttributes: 'uploadId' } ); + } ); + } ); + + afterEach( async () => { + return editor.destroy(); + } ); + + it( 'should insert inline image in a paragraph with text', () => { + setModelData( model, 'f[o]o' ); + + imageUtils.insertImage( editor ); + + expect( getModelData( model ) ).to.equal( 'f[]o' ); + } ); + + it( 'should insert a block image when the selection is inside an empty paragraph', () => { + setModelData( model, '[]' ); + + imageUtils.insertImage( editor ); + + expect( getModelData( model ) ).to.equal( '[]' ); + } ); + + it( 'should insert a block image in the document root', () => { + setModelData( model, '[]' ); + + imageUtils.insertImage( editor ); + + expect( getModelData( model ) ).to.equal( '[]' ); + } ); + + it( 'should insert image with given attributes', () => { + setModelData( model, 'f[o]o' ); + + imageUtils.insertImage( { src: 'bar' } ); + + expect( getModelData( model ) ).to.equal( 'f[]o' ); + } ); + + it( 'should not insert image nor crash when image could not be inserted', () => { + model.schema.register( 'other', { + allowIn: '$root', + allowChildren: '$text', + isLimit: true + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'other', view: 'p' } ); + + setModelData( model, '[]' ); + + imageUtils.insertImage(); + + expect( getModelData( model ) ).to.equal( '[]' ); + } ); + + it( 'should use the block image type when the config.image.insert.type="block" option is set', async () => { + const newEditor = await VirtualTestEditor.create( { + plugins: [ ImageUtils, ImageBlockEditing, ImageInlineEditing, Paragraph ], + image: { insert: { type: 'block' } } + } ); + + setModelData( newEditor.model, 'f[o]o' ); + + newEditor.plugins.get( 'ImageUtils' ).insertImage( newEditor ); + + expect( getModelData( newEditor.model ) ).to.equal( '[]foo' ); + + await newEditor.destroy(); + } ); + + it( 'should use the inline image type if the config.image.insert.type="inline" option is set', async () => { + const newEditor = await VirtualTestEditor.create( { + plugins: [ ImageUtils, ImageBlockEditing, ImageInlineEditing, Paragraph ], + image: { insert: { type: 'inline' } } + } ); + + setModelData( newEditor.model, 'f[o]o' ); + + newEditor.plugins.get( 'ImageUtils' ).insertImage(); + + expect( getModelData( newEditor.model ) ).to.equal( 'f[]o' ); + + await newEditor.destroy(); + } ); + + it( 'should use the inline image type when there is only ImageInlineEditing plugin enabled', async () => { + const newEditor = await VirtualTestEditor.create( { + plugins: [ ImageUtils, ImageInlineEditing, Paragraph ] + } ); + + setModelData( newEditor.model, 'f[o]o' ); + + newEditor.plugins.get( 'ImageUtils' ).insertImage(); + + expect( getModelData( newEditor.model ) ).to.equal( 'f[]o' ); + + await newEditor.destroy(); + } ); + + it( 'should use block the image type when there is only ImageBlockEditing plugin enabled', async () => { + const newEditor = await VirtualTestEditor.create( { + plugins: [ ImageUtils, ImageBlockEditing, Paragraph ] + } ); + + setModelData( newEditor.model, 'f[o]o' ); + + newEditor.plugins.get( 'ImageUtils' ).insertImage(); + + expect( getModelData( newEditor.model ) ).to.equal( '[]foo' ); + + await newEditor.destroy(); + } ); + + it( 'should use the block image type when the config.image.insert.type="inline" option is set ' + + 'but ImageInlineEditing plugin is not enabled', async () => { + const consoleWarnStub = sinon.stub( console, 'warn' ); + const newEditor = await VirtualTestEditor.create( { + plugins: [ ImageUtils, ImageBlockEditing, Paragraph ], + image: { insert: { type: 'inline' } } + } ); + + setModelData( newEditor.model, 'f[o]o' ); + + newEditor.plugins.get( 'ImageUtils' ).insertImage(); + + expect( consoleWarnStub.calledOnce ).to.equal( true ); + expect( consoleWarnStub.firstCall.args[ 0 ] ).to.equal( 'image-inline-plugin-required' ); + expect( getModelData( newEditor.model ) ).to.equal( '[]foo' ); + + await newEditor.destroy(); + console.warn.restore(); + } ); + + it( 'should use the inline image type when the image.insert.type="block" option is set ' + + 'but ImageBlockEditing plugin is not enabled', async () => { + const consoleWarnStub = sinon.stub( console, 'warn' ); + const newEditor = await VirtualTestEditor.create( { + plugins: [ ImageUtils, ImageInlineEditing, Paragraph ], + image: { insert: { type: 'block' } } + } ); + + setModelData( newEditor.model, 'f[o]o' ); + + newEditor.plugins.get( 'ImageUtils' ).insertImage(); + + expect( consoleWarnStub.calledOnce ).to.equal( true ); + expect( consoleWarnStub.firstCall.args[ 0 ] ).to.equal( 'image-block-plugin-required' ); + expect( getModelData( newEditor.model ) ).to.equal( 'f[]o' ); + + await newEditor.destroy(); + console.warn.restore(); + } ); + + it( 'should pass the allowed custom attributes to the inserted block image', () => { + setModelData( model, '[]' ); + model.schema.extend( 'image', { allowAttributes: 'customAttribute' } ); + + imageUtils.insertImage( { src: 'foo', customAttribute: 'value' } ); + + expect( getModelData( model ) ) + .to.equal( '[]' ); + } ); + + it( 'should omit the disallowed attributes while inserting a block image', () => { + setModelData( model, '[]' ); + + imageUtils.insertImage( { src: 'foo', customAttribute: 'value' } ); + + expect( getModelData( model ) ) + .to.equal( '[]' ); + } ); + + it( 'should pass the allowed custom attributes to the inserted inline image', () => { + setModelData( model, 'f[o]o' ); + model.schema.extend( 'imageInline', { allowAttributes: 'customAttribute' } ); + + imageUtils.insertImage( { src: 'foo', customAttribute: 'value' } ); + + expect( getModelData( model ) ) + .to.equal( 'f[]o' ); + } ); + + it( 'should omit the disallowed attributes while inserting an inline image', () => { + setModelData( model, 'f[o]o' ); + + imageUtils.insertImage( { src: 'foo', customAttribute: 'value' } ); + + expect( getModelData( model ) ).to.equal( 'f[]o' ); + } ); + } ); + + describe( 'getViewImageFromWidget()', () => { + // figure + // img + it( 'returns the the img element from widget if the img is the first children', () => { + expect( imageUtils.getViewImageFromWidget( element ) ).to.equal( image ); + } ); + + // figure + // div + // img + it( 'returns the the img element from widget if the img is not the first children', () => { + writer.insert( writer.createPositionAt( element, 0 ), writer.createContainerElement( 'div' ) ); + expect( imageUtils.getViewImageFromWidget( element ) ).to.equal( image ); + } ); + + // figure + // div + // img + it( 'returns the the img element from widget if the img is a child of another element', () => { + const divElement = writer.createContainerElement( 'div' ); + + writer.insert( writer.createPositionAt( element, 0 ), divElement ); + writer.move( writer.createRangeOn( image ), writer.createPositionAt( divElement, 0 ) ); + + expect( imageUtils.getViewImageFromWidget( element ) ).to.equal( image ); + } ); + + // figure + // div + // "Bar" + // img + // "Foo" + it( 'does not throw an error if text node found', () => { + const divElement = writer.createContainerElement( 'div' ); + + writer.insert( writer.createPositionAt( element, 0 ), divElement ); + writer.insert( writer.createPositionAt( element, 0 ), writer.createText( 'Foo' ) ); + writer.insert( writer.createPositionAt( divElement, 0 ), writer.createText( 'Bar' ) ); + writer.move( writer.createRangeOn( image ), writer.createPositionAt( divElement, 1 ) ); + + expect( imageUtils.getViewImageFromWidget( element ) ).to.equal( image ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-image/tests/manual/imageinsertviaurl.js b/packages/ckeditor5-image/tests/manual/imageinsertviaurl.js index fcf641f4ab5..a795cf6bfb5 100644 --- a/packages/ckeditor5-image/tests/manual/imageinsertviaurl.js +++ b/packages/ckeditor5-image/tests/manual/imageinsertviaurl.js @@ -8,13 +8,14 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; import CKFinderUploadAdapter from '@ckeditor/ckeditor5-adapter-ckfinder/src/uploadadapter'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage'; import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder'; import ImageInsert from '../../src/imageinsert'; import AutoImage from '../../src/autoimage'; ClassicEditor .create( document.querySelector( '#editor' ), { - plugins: [ ArticlePluginSet, ImageInsert, AutoImage, CKFinderUploadAdapter, CKFinder ], + plugins: [ ArticlePluginSet, ImageInsert, AutoImage, LinkImage, CKFinderUploadAdapter, CKFinder ], toolbar: [ 'heading', '|', diff --git a/packages/ckeditor5-image/theme/image.css b/packages/ckeditor5-image/theme/image.css index 66152940a0e..eabbb4ad9c5 100644 --- a/packages/ckeditor5-image/theme/image.css +++ b/packages/ckeditor5-image/theme/image.css @@ -62,7 +62,7 @@ padding-left: inherit; padding-right: inherit; - /* + /* * Make sure the image caption placeholder doesn't overflow the placeholder area. * See https://github.com/ckeditor/ckeditor5/issues/9162. */ @@ -77,6 +77,15 @@ */ & .image-inline.ck-widget_selected { z-index: 1; + + /* + * Make sure the native browser selection style is not displayed. + * Inline image widgets have their own styles for the selected state and + * leaving this up to the browser is asking for a visual collision. + */ + & ::selection { + display: none; + } } /* The inline image nested in the table should have its original size if not resized. diff --git a/packages/ckeditor5-image/theme/imageuploadicon.css b/packages/ckeditor5-image/theme/imageuploadicon.css index 1b250878de7..4fbb9f7f206 100644 --- a/packages/ckeditor5-image/theme/imageuploadicon.css +++ b/packages/ckeditor5-image/theme/imageuploadicon.css @@ -6,9 +6,15 @@ .ck-image-upload-complete-icon { display: block; position: absolute; - top: 10px; - right: 10px; + + /* + * Smaller images should have the icon closer to the border. + * Match the icon position with the linked image indicator brought by the link image feature. + */ + top: min(var(--ck-spacing-medium), 6%); + right: min(var(--ck-spacing-medium), 6%); border-radius: 50%; + z-index: 1; &::after { content: ""; diff --git a/packages/ckeditor5-link/package.json b/packages/ckeditor5-link/package.json index 247f6477c2c..62347d43552 100644 --- a/packages/ckeditor5-link/package.json +++ b/packages/ckeditor5-link/package.json @@ -19,9 +19,11 @@ "@ckeditor/ckeditor5-basic-styles": "^27.0.0", "@ckeditor/ckeditor5-block-quote": "^27.0.0", "@ckeditor/ckeditor5-clipboard": "^27.0.0", + "@ckeditor/ckeditor5-cloud-services": "^27.0.0", "@ckeditor/ckeditor5-code-block": "^27.0.0", "@ckeditor/ckeditor5-core": "^27.0.0", "@ckeditor/ckeditor5-dev-utils": "^24.0.0", + "@ckeditor/ckeditor5-easy-image": "^27.0.0", "@ckeditor/ckeditor5-editor-classic": "^27.0.0", "@ckeditor/ckeditor5-engine": "^27.0.0", "@ckeditor/ckeditor5-enter": "^27.0.0", @@ -31,6 +33,7 @@ "@ckeditor/ckeditor5-typing": "^27.0.0", "@ckeditor/ckeditor5-undo": "^27.0.0", "@ckeditor/ckeditor5-utils": "^27.0.0", + "@ckeditor/ckeditor5-widget": "^27.0.0", "webpack": "^4.43.0", "webpack-cli": "^3.3.11" }, diff --git a/packages/ckeditor5-link/src/linkcommand.js b/packages/ckeditor5-link/src/linkcommand.js index 7bcaaf68384..e4ea517ee99 100644 --- a/packages/ckeditor5-link/src/linkcommand.js +++ b/packages/ckeditor5-link/src/linkcommand.js @@ -9,10 +9,10 @@ import { Command } from 'ckeditor5/src/core'; import { findAttributeRange } from 'ckeditor5/src/typing'; -import { Collection, toMap, first } from 'ckeditor5/src/utils'; +import { Collection, first, toMap } from 'ckeditor5/src/utils'; import AutomaticDecorators from './utils/automaticdecorators'; -import { isImageAllowed } from './utils'; +import { isLinkableElement } from './utils'; /** * The link command. It is used by the {@link module:link/link~Link link feature}. @@ -66,18 +66,17 @@ export default class LinkCommand extends Command { */ refresh() { const model = this.editor.model; - const doc = model.document; - - const selectedElement = first( doc.selection.getSelectedBlocks() ); + const selection = model.document.selection; + const selectedElement = selection.getSelectedElement() || first( selection.getSelectedBlocks() ); - // A check for the `LinkImage` plugin. If the selection contains an element, get values from the element. + // A check for any integration that allows linking elements (e.g. `LinkImage`). // Currently the selection reads attributes from text nodes only. See #7429 and #7465. - if ( isImageAllowed( selectedElement, model.schema ) ) { + if ( isLinkableElement( selectedElement, model.schema ) ) { this.value = selectedElement.getAttribute( 'linkHref' ); this.isEnabled = model.schema.checkAttribute( selectedElement, 'linkHref' ); } else { - this.value = doc.selection.getAttribute( 'linkHref' ); - this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' ); + this.value = selection.getAttribute( 'linkHref' ); + this.isEnabled = model.schema.checkAttributeInSelection( selection, 'linkHref' ); } for ( const manualDecorator of this.manualDecorators ) { @@ -258,17 +257,16 @@ export default class LinkCommand extends Command { */ _getDecoratorStateFromModel( decoratorName ) { const model = this.editor.model; - const doc = model.document; - - const selectedElement = first( doc.selection.getSelectedBlocks() ); + const selection = model.document.selection; + const selectedElement = selection.getSelectedElement(); // A check for the `LinkImage` plugin. If the selection contains an element, get values from the element. // Currently the selection reads attributes from text nodes only. See #7429 and #7465. - if ( isImageAllowed( selectedElement, model.schema ) ) { + if ( isLinkableElement( selectedElement, model.schema ) ) { return selectedElement.getAttribute( decoratorName ); } - return doc.selection.getAttribute( decoratorName ); + return selection.getAttribute( decoratorName ); } /** diff --git a/packages/ckeditor5-link/src/linkimageediting.js b/packages/ckeditor5-link/src/linkimageediting.js index 0aa3c967371..10699d6f9c7 100644 --- a/packages/ckeditor5-link/src/linkimageediting.js +++ b/packages/ckeditor5-link/src/linkimageediting.js @@ -13,8 +13,6 @@ import { toMap } from 'ckeditor5/src/utils'; import LinkEditing from './linkediting'; -import linkIcon from '../theme/icons/link.svg'; - /** * The link image engine feature. * @@ -28,7 +26,7 @@ export default class LinkImageEditing extends Plugin { * @inheritDoc */ static get requires() { - return [ 'ImageEditing', LinkEditing ]; + return [ 'ImageEditing', 'ImageUtils', LinkEditing ]; } /** @@ -50,9 +48,8 @@ export default class LinkImageEditing extends Plugin { schema.extend( 'imageInline', { allowAttributes: [ 'linkHref' ] } ); } - editor.conversion.for( 'upcast' ).add( upcastLink() ); - editor.conversion.for( 'editingDowncast' ).add( downcastImageLink( { attachIconIndicator: true } ) ); - editor.conversion.for( 'dataDowncast' ).add( downcastImageLink( { attachIconIndicator: false } ) ); + editor.conversion.for( 'upcast' ).add( upcastLink( editor ) ); + editor.conversion.for( 'downcast' ).add( downcastImageLink ); // Definitions for decorators are provided by the `link` command and the `LinkEditing` plugin. this._enableAutomaticDecorators(); @@ -101,11 +98,16 @@ export default class LinkImageEditing extends Plugin { } } -// Returns a converter that consumes the 'href' attribute if a link contains an image. +// Returns a converter for linked block images that consumes the "href" attribute +// if a link contains an image. // // @private +// @param {module:core/editor/editor~Editor} editor The editor instance. // @returns {Function} -function upcastLink() { +function upcastLink( editor ) { + const isImageInlinePluginLoaded = editor.plugins.has( 'ImageInlineEditing' ); + const linkUtils = editor.plugins.get( 'ImageUtils' ); + return dispatcher => { dispatcher.on( 'element:a', ( evt, data, conversionApi ) => { const viewLink = data.viewItem; @@ -115,6 +117,19 @@ function upcastLink() { return; } + const blockImageView = imageInLink.findAncestor( element => linkUtils.isBlockImageView( element ) ); + + // There are two possible cases to consider here + // + // 1. A "root > ... > figure.image > a > img" structure. + // 2. A "root > ... > block > a > img" structure. + // + // but the latter should only be considered by this converter when the inline image plugin + // is NOT loaded in the editor (because otherwise, that would be a plain, linked inline image). + if ( isImageInlinePluginLoaded && !blockImageView ) { + return; + } + // There's an image inside an element - we consume it so it won't be picked up by the Link plugin. const consumableAttributes = { attributes: [ 'href' ] }; @@ -158,62 +173,40 @@ function upcastLink() { }; } -// Return a converter that adds the `` element to data. +// Creates a converter that adds `` to linked block image view elements. // // @private -// @params {Object} options -// @params {Boolean} options.attachIconIndicator=false If set to `true`, an icon that informs about the linked image will be added. -// @returns {Function} -function downcastImageLink( options ) { - return dispatcher => { - dispatcher.on( 'attribute:linkHref:image', ( evt, data, conversionApi ) => { - // The image will be already converted - so it will be present in the view. - const viewFigure = conversionApi.mapper.toViewElement( data.item ); - const writer = conversionApi.writer; - - // But we need to check whether the link element exists. - const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' ); - - let linkIconIndicator; - - if ( options.attachIconIndicator ) { - // Create an icon indicator for a linked image. - linkIconIndicator = writer.createUIElement( 'span', { class: 'ck ck-link-image_icon' }, function( domDocument ) { - const domElement = this.toDomElement( domDocument ); - domElement.innerHTML = linkIcon; - - return domElement; - } ); - } - - // If so, update the attribute if it's defined or remove the entire link if the attribute is empty. - if ( linkInImage ) { - if ( data.attributeNewValue ) { - writer.setAttribute( 'href', data.attributeNewValue, linkInImage ); - } else { - const viewImage = Array.from( linkInImage.getChildren() ).find( child => child.name === 'img' ); - - writer.move( writer.createRangeOn( viewImage ), writer.createPositionAt( viewFigure, 0 ) ); - writer.remove( linkInImage ); - } +function downcastImageLink( dispatcher ) { + dispatcher.on( 'attribute:linkHref:image', ( evt, data, conversionApi ) => { + // The image will be already converted - so it will be present in the view. + const viewFigure = conversionApi.mapper.toViewElement( data.item ); + const writer = conversionApi.writer; + + // But we need to check whether the link element exists. + const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' ); + + // If so, update the attribute if it's defined or remove the entire link if the attribute is empty. + if ( linkInImage ) { + if ( data.attributeNewValue ) { + writer.setAttribute( 'href', data.attributeNewValue, linkInImage ); } else { - // But if it does not exist. Let's wrap already converted image by newly created link element. - // 1. Create an empty link element. - const linkElement = writer.createContainerElement( 'a', { href: data.attributeNewValue } ); + const viewImage = Array.from( linkInImage.getChildren() ).find( child => child.name === 'img' ); - // 2. Insert link inside the associated image. - writer.insert( writer.createPositionAt( viewFigure, 0 ), linkElement ); + writer.move( writer.createRangeOn( viewImage ), writer.createPositionAt( viewFigure, 0 ) ); + writer.remove( linkInImage ); + } + } else { + // But if it does not exist. Let's wrap already converted image by newly created link element. + // 1. Create an empty link element. + const linkElement = writer.createContainerElement( 'a', { href: data.attributeNewValue } ); - // 3. Move the image to the link. - writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( linkElement, 0 ) ); + // 2. Insert link inside the associated image. + writer.insert( writer.createPositionAt( viewFigure, 0 ), linkElement ); - // 4. Inset the linked image icon indicator while downcast to editing. - if ( linkIconIndicator ) { - writer.insert( writer.createPositionAt( linkElement, 'end' ), linkIconIndicator ); - } - } - } ); - }; + // 3. Move the image to the link. + writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( linkElement, 0 ) ); + } + } ); } // Returns a converter that decorates the `` element when the image is the link label. @@ -224,7 +217,6 @@ function downcastImageLinkManualDecorator( manualDecorators, decorator ) { return dispatcher => { dispatcher.on( `attribute:${ decorator.id }:image`, ( evt, data, conversionApi ) => { const attributes = manualDecorators.get( decorator.id ).attributes; - const viewFigure = conversionApi.mapper.toViewElement( data.item ); const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' ); diff --git a/packages/ckeditor5-link/src/linkimageui.js b/packages/ckeditor5-link/src/linkimageui.js index 653050bfebe..1dc232a04a2 100644 --- a/packages/ckeditor5-link/src/linkimageui.js +++ b/packages/ckeditor5-link/src/linkimageui.js @@ -47,10 +47,9 @@ export default class LinkImageUI extends Plugin { const editor = this.editor; const viewDocument = editor.editing.view.document; + // Prevent browser navigation when clicking a linked image. this.listenTo( viewDocument, 'click', ( evt, data ) => { - const hasLink = isImageLinked( viewDocument.selection.getSelectedElement(), editor.plugins.get( 'Image' ) ); - - if ( hasLink ) { + if ( this._isSelectedLinkedImage( editor.model.document.selection ) ) { data.preventDefault(); } } ); @@ -91,9 +90,7 @@ export default class LinkImageUI extends Plugin { // Show the actionsView or formView (both from LinkUI) on button click depending on whether the image is linked already. this.listenTo( button, 'execute', () => { - const hasLink = isImageLinked( editor.editing.view.document.selection.getSelectedElement(), editor.plugins.get( 'Image' ) ); - - if ( hasLink ) { + if ( this._isSelectedLinkedImage( editor.model.document.selection ) ) { plugin._addActionsView(); } else { plugin._showUI( true ); @@ -103,18 +100,19 @@ export default class LinkImageUI extends Plugin { return button; } ); } -} -// A helper function that checks whether the element is a linked image. -// -// @param {module:engine/model/element~Element} element -// @returns {Boolean} -function isImageLinked( element, image ) { - const isImage = element && image.isImageWidget( element ); + /** + * Returns true if a linked image (either block or inline) is the only selected element + * in the model document. + * + * @private + * @param {module:engine/model/selection~Selection} selection + * @returns {Boolean} + */ + _isSelectedLinkedImage( selection ) { + const selectedModelElement = selection.getSelectedElement(); + const imageUtils = this.editor.plugins.get( 'ImageUtils' ); - if ( !isImage ) { - return false; + return imageUtils.isImage( selectedModelElement ) && selectedModelElement.hasAttribute( 'linkHref' ); } - - return element.getChild( 0 ).is( 'element', 'a' ); } diff --git a/packages/ckeditor5-link/src/linkui.js b/packages/ckeditor5-link/src/linkui.js index 576dcec69a8..c665c1cac37 100644 --- a/packages/ckeditor5-link/src/linkui.js +++ b/packages/ckeditor5-link/src/linkui.js @@ -10,6 +10,7 @@ import { Plugin } from 'ckeditor5/src/core'; import { ClickObserver } from 'ckeditor5/src/engine'; import { ButtonView, ContextualBalloon, clickOutsideHandler } from 'ckeditor5/src/ui'; +import { isWidget } from 'ckeditor5/src/widget'; import LinkFormView from './ui/linkformview'; import LinkActionsView from './ui/linkactionsview'; @@ -593,14 +594,19 @@ export default class LinkUI extends Plugin { target = view.domConverter.viewRangeToDom( newRange ); } else { - const targetLink = this._getSelectedLinkElement(); - const range = viewDocument.selection.getFirstRange(); - - target = targetLink ? - // When selection is inside link element, then attach panel to this element. - view.domConverter.mapViewToDom( targetLink ) : - // Otherwise attach panel to the selection. - view.domConverter.viewRangeToDom( range ); + // Make sure the target is calculated on demand at the last moment because a cached DOM range + // (which is very fragile) can desynchronize with the state of the editing view if there was + // any rendering done in the meantime. This can happen, for instance, when an inline widget + // gets unlinked. + target = () => { + const targetLink = this._getSelectedLinkElement(); + + return targetLink ? + // When selection is inside link element, then attach panel to this element. + view.domConverter.mapViewToDom( targetLink ) : + // Otherwise attach panel to the selection. + view.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() ); + }; } return { target }; @@ -636,6 +642,13 @@ export default class LinkUI extends Plugin { // Check if the link element is fully selected. if ( view.createRangeIn( startLink ).getTrimmed().isEqual( range ) ) { + // The link element is not fully selected when, for instance, there's an inline image widget directly inside. + // This should be interpreted as a selected inline image and the image UI should be displayed instead + // (LinkUI should not interact). + if ( startLink.childCount === 1 && isWidget( startLink.getChild( 0 ) ) ) { + return null; + } + return startLink; } else { return null; diff --git a/packages/ckeditor5-link/src/unlinkcommand.js b/packages/ckeditor5-link/src/unlinkcommand.js index 6478b89d06a..438feb32342 100644 --- a/packages/ckeditor5-link/src/unlinkcommand.js +++ b/packages/ckeditor5-link/src/unlinkcommand.js @@ -9,9 +9,8 @@ import { Command } from 'ckeditor5/src/core'; import { findAttributeRange } from 'ckeditor5/src/typing'; -import { first } from 'ckeditor5/src/utils'; -import { isImageAllowed } from './utils'; +import { isLinkableElement } from './utils'; /** * The unlink command. It is used by the {@link module:link/link~Link link plugin}. @@ -24,16 +23,15 @@ export default class UnlinkCommand extends Command { */ refresh() { const model = this.editor.model; - const doc = model.document; - - const selectedElement = first( doc.selection.getSelectedBlocks() ); + const selection = model.document.selection; + const selectedElement = selection.getSelectedElement(); - // A check for the `LinkImage` plugin. If the selection contains an image element, get values from the element. + // A check for any integration that allows linking elements (e.g. `LinkImage`). // Currently the selection reads attributes from text nodes only. See #7429 and #7465. - if ( isImageAllowed( selectedElement, model.schema ) ) { + if ( isLinkableElement( selectedElement, model.schema ) ) { this.isEnabled = model.schema.checkAttribute( selectedElement, 'linkHref' ); } else { - this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' ); + this.isEnabled = model.schema.checkAttributeInSelection( selection, 'linkHref' ); } } diff --git a/packages/ckeditor5-link/src/utils.js b/packages/ckeditor5-link/src/utils.js index f3b925cc47f..3d668ba1ef3 100644 --- a/packages/ckeditor5-link/src/utils.js +++ b/packages/ckeditor5-link/src/utils.js @@ -129,18 +129,18 @@ export function normalizeDecorators( decorators ) { } /** - * Returns `true` if the specified `element` is an image and it can be linked (the element allows having the `linkHref` attribute). + * Returns `true` if the specified `element` can be linked (the element allows the `linkHref` attribute). * * @params {module:engine/model/element~Element|null} element * @params {module:engine/model/schema~Schema} schema * @returns {Boolean} */ -export function isImageAllowed( element, schema ) { +export function isLinkableElement( element, schema ) { if ( !element ) { return false; } - return element.is( 'element', 'image' ) && schema.checkAttribute( 'image', 'linkHref' ); + return schema.checkAttribute( element.name, 'linkHref' ); } /** diff --git a/packages/ckeditor5-link/tests/linkcommand.js b/packages/ckeditor5-link/tests/linkcommand.js index ca707e563f5..6e918155d5c 100644 --- a/packages/ckeditor5-link/tests/linkcommand.js +++ b/packages/ckeditor5-link/tests/linkcommand.js @@ -24,7 +24,7 @@ describe( 'LinkCommand', () => { allowAttributes: [ 'linkHref', 'bold' ] } ); - model.schema.register( 'p', { inheritAllFrom: '$block' } ); + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); } ); } ); @@ -48,7 +48,7 @@ describe( 'LinkCommand', () => { describe( 'when selection is collapsed', () => { it( 'should be true if characters with the attribute can be placed at caret position', () => { - setData( model, '

f[]oo

' ); + setData( model, 'f[]oo' ); expect( command.isEnabled ).to.be.true; } ); @@ -60,7 +60,7 @@ describe( 'LinkCommand', () => { describe( 'when selection is not collapsed', () => { it( 'should be true if there is at least one node in selection that can have the attribute', () => { - setData( model, '

[foo]

' ); + setData( model, '[foo]' ); expect( command.isEnabled ).to.be.true; } ); @@ -69,36 +69,40 @@ describe( 'LinkCommand', () => { expect( command.isEnabled ).to.be.false; } ); - describe( 'for images', () => { + describe( 'for linkable block elements', () => { beforeEach( () => { - model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } ); + model.schema.register( 'linkableBlock', { + isBlock: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); } ); - it( 'should be true when an image is selected', () => { - setData( model, '[]' ); + it( 'should be true when a linkable is selected', () => { + setData( model, '[]' ); expect( command.isEnabled ).to.be.true; } ); - it( 'should be true when an image and a text are selected', () => { - setData( model, '[Foo]' ); + it( 'should be true when a linkable and a text are selected', () => { + setData( model, '[Foo]' ); expect( command.isEnabled ).to.be.true; } ); - it( 'should be true when a text and an image are selected', () => { - setData( model, '[Foo]' ); + it( 'should be true when a text and a linkable are selected', () => { + setData( model, '[Foo]' ); expect( command.isEnabled ).to.be.true; } ); - it( 'should be true when two images are selected', () => { - setData( model, '[]' ); + it( 'should be true when two linkables are selected', () => { + setData( model, '[]' ); expect( command.isEnabled ).to.be.true; } ); - it( 'should be false when a fake image is selected', () => { + it( 'should be false when a fake linkable is selected', () => { model.schema.register( 'fake', { isBlock: true, allowWhere: '$text' } ); setData( model, '[]' ); @@ -106,14 +110,65 @@ describe( 'LinkCommand', () => { expect( command.isEnabled ).to.be.false; } ); - it( 'should be false if an image does not accept the `linkHref` attribute in given context', () => { + it( 'should be false if a linkable does not accept the `linkHref` attribute in given context', () => { + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + if ( ctx.endsWith( '$root linkableBlock' ) && attributeName == 'linkHref' ) { + return false; + } + } ); + + setData( model, '[]' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'for linkable inline elements', () => { + beforeEach( () => { + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + } ); + + it( 'should be true when a linkable is selected', () => { + setData( model, 'foo []' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when a linkable and a text are selected', () => { + setData( model, 'foo [bar]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when a text and a linkable are selected', () => { + setData( model, '[foo]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when two linkables are selected', () => { + setData( model, + '' + + 'foo ' + + '[]' + + '' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if a linkable does not accept the `linkHref` attribute in given context', () => { model.schema.addAttributeCheck( ( ctx, attributeName ) => { - if ( ctx.endsWith( '$root image' ) && attributeName == 'linkHref' ) { + if ( ctx.endsWith( 'linkableInline' ) && attributeName == 'linkHref' ) { return false; } } ); - setData( model, '[]' ); + setData( model, '[]' ); expect( command.isEnabled ).to.be.false; } ); @@ -150,30 +205,39 @@ describe( 'LinkCommand', () => { } ); } ); - describe( 'for images', () => { + describe( 'for linkable block elements', () => { beforeEach( () => { - model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } ); + model.schema.register( 'linkableBlock', { + isBlock: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); } ); - it( 'should read the value from a selected image', () => { - setData( model, '[]' ); + it( 'should read the value from a selected linkable', () => { + setData( model, '[]' ); expect( command.value ).to.be.equal( 'foo' ); } ); - it( 'should read the value from a selected image and ignore a text node', () => { - setData( model, '[

<$text linkHref="bar">bar]

' ); + it( 'should read the value from a selected linkable and ignore a text node', () => { + setData( model, + '[' + + '<$text linkHref="bar">bar]' + ); expect( command.value ).to.be.equal( 'foo' ); } ); - it( 'should read the value from a selected text node and ignore an image', () => { - setData( model, '

[<$text linkHref="bar">bar

]' ); + it( 'should read the value from a selected text node and ignore a linkable', () => { + setData( model, + '[<$text linkHref="bar">bar]' + ); expect( command.value ).to.be.equal( 'bar' ); } ); - it( 'should be undefined when a fake image is selected', () => { + it( 'should be undefined when a fake linkable is selected', () => { model.schema.register( 'fake', { isBlock: true, allowWhere: '$text' } ); setData( model, '[]' ); @@ -181,6 +245,42 @@ describe( 'LinkCommand', () => { expect( command.value ).to.be.undefined; } ); } ); + + describe( 'for linkable inline elements', () => { + beforeEach( () => { + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + } ); + + it( 'should read the value from a selected linkable', () => { + setData( model, '[]' ); + + expect( command.value ).to.be.equal( 'foo' ); + } ); + + // NOTE: The command value should most likely be "foo" but this requires a lot changes in refresh() + // because it relies on getSelectedElement()/getSelectedBlocks() and neither will return the inline widget + // in this case. + it( 'should not read the value from a selected linkable when a linked text follows it', () => { + setData( model, + '[<$text linkHref="bar">bar]' + ); + + expect( command.value ).to.be.undefined; + } ); + + it( 'should read the value from a selected text node and ignore a linkable', () => { + setData( model, + '[<$text linkHref="bar">bar]' + ); + + expect( command.value ).to.be.equal( 'bar' ); + } ); + } ); } ); describe( 'execute()', () => { @@ -265,87 +365,192 @@ describe( 'LinkCommand', () => { } ); it( 'should set `linkHref` attribute to selected text when text is split by $block element', () => { - setData( model, '

f[oo

ba]r

' ); + setData( model, 'f[ooba]r' ); expect( command.value ).to.be.undefined; command.execute( 'url' ); - expect( getData( model ) ) - .to.equal( '

f[<$text linkHref="url">oo

<$text linkHref="url">ba]r

' ); + expect( getData( model ) ).to.equal( + 'f[<$text linkHref="url">oo<$text linkHref="url">ba]r' + ); expect( command.value ).to.equal( 'url' ); } ); - it( 'should set `linkHref` attribute to allowed elements', () => { - model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } ); + describe( 'for block elements allowing linkHref', () => { + it( 'should set `linkHref` attribute to allowed elements', () => { + model.schema.register( 'linkableBlock', { + isBlock: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); - setData( model, '

f[ooba]r

' ); + setData( model, 'f[ooba]r' ); - expect( command.value ).to.be.undefined; + expect( command.value ).to.be.undefined; - command.execute( 'url' ); + command.execute( 'url' ); - expect( getData( model ) ).to.equal( - '

f[<$text linkHref="url">oo<$text linkHref="url">ba]r

' - ); - expect( command.value ).to.equal( 'url' ); - } ); + expect( getData( model ) ).to.equal( + '' + + 'f[<$text linkHref="url">oo' + + '' + + '<$text linkHref="url">ba]r' + + '' + ); + expect( command.value ).to.equal( 'url' ); + } ); - it( 'should set `linkHref` attribute to nested allowed elements', () => { - model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } ); - model.schema.register( 'blockQuote', { allowWhere: '$block', allowContentOf: '$root' } ); + it( 'should set `linkHref` attribute to nested allowed elements', () => { + model.schema.register( 'linkableBlock', { + isBlock: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + model.schema.register( 'blockQuote', { allowWhere: '$block', allowContentOf: '$root' } ); - setData( model, '

foo

[
]

bar

' ); + setData( model, + 'foo[
]bar' + ); - command.execute( 'url' ); + command.execute( 'url' ); - expect( getData( model ) ) - .to.equal( '

foo

[
]

bar

' ); - } ); + expect( getData( model ) ).to.equal( + 'foo' + + '[
]' + + 'bar' ); + } ); - it( 'should set `linkHref` attribute to allowed elements on multi-selection', () => { - model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } ); + it( 'should set `linkHref` attribute to allowed elements on multi-selection', () => { + model.schema.register( 'linkableBlock', { + isBlock: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); - setData( model, '

[][]

' ); + setData( model, '[][]' ); - command.execute( 'url' ); + command.execute( 'url' ); - expect( getData( model ) ) - .to.equal( '

[][]

' ); - } ); + expect( getData( model ) ).to.equal( + '' + + '[][]' + + '' + ); + } ); - it( 'should set `linkHref` attribute to allowed elements and omit disallowed', () => { - model.schema.register( 'image', { isBlock: true, allowWhere: '$text' } ); - model.schema.register( 'caption', { allowIn: 'image', allowChildren: '$text' } ); + it( 'should set `linkHref` attribute to allowed elements and omit disallowed', () => { + model.schema.register( 'linkableBlock', { + isBlock: true, + allowWhere: '$text' + } ); + model.schema.register( 'caption', { allowIn: 'linkableBlock' } ); + model.schema.extend( '$text', { allowIn: 'caption' } ); - setData( model, '

f[ooxxxba]r

' ); + setData( model, 'f[ooxxxba]r' ); - command.execute( 'url' ); + command.execute( 'url' ); - expect( getData( model ) ).to.equal( - '

' + - 'f[<$text linkHref="url">oo' + - '<$text linkHref="url">xxx' + - '<$text linkHref="url">ba]r' + - '

' - ); + expect( getData( model ) ).to.equal( + '' + + 'f[<$text linkHref="url">oo' + + '<$text linkHref="url">xxx' + + '<$text linkHref="url">ba]r' + + '' + ); + } ); + + it( 'should set `linkHref` attribute to allowed elements and omit their children even if they accept the attribute', () => { + model.schema.register( 'linkableBlock', { + isBlock: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + model.schema.register( 'caption', { allowIn: 'linkableBlock' } ); + model.schema.extend( '$text', { allowIn: 'caption' } ); + + setData( model, 'f[ooxxxba]r' ); + + command.execute( 'url' ); + + expect( getData( model ) ).to.equal( + '' + + 'f[<$text linkHref="url">oo' + + 'xxx' + + '<$text linkHref="url">ba]r' + + '' + ); + } ); } ); - it( 'should set `linkHref` attribute to allowed elements and omit their children even if they accept the attribute', () => { - model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } ); - model.schema.register( 'caption', { allowIn: 'image', allowChildren: '$text' } ); + describe( 'for inline elements allowing linkHref', () => { + it( 'should set `linkHref` attribute to allowed elements', () => { + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); - setData( model, '

f[ooxxxba]r

' ); + setData( model, 'f[ooba]r' ); - command.execute( 'url' ); + expect( command.value ).to.be.undefined; - expect( getData( model ) ).to.equal( - '

' + - 'f[<$text linkHref="url">oo' + - 'xxx' + - '<$text linkHref="url">ba]r' + - '

' - ); + command.execute( 'url' ); + + expect( getData( model ) ).to.equal( + '' + + 'f[<$text linkHref="url">oo' + + '' + + '<$text linkHref="url">ba]r' + + '' + ); + + expect( command.value ).to.equal( 'url' ); + } ); + + it( 'should set `linkHref` attribute to nested allowed elements', () => { + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + model.schema.register( 'blockQuote', { allowWhere: '$block', allowContentOf: '$root' } ); + + setData( model, + 'foo' + + '[
]' + + 'bar' + ); + + command.execute( 'url' ); + + expect( getData( model ) ).to.equal( + 'foo' + + '[
]' + + 'bar' + ); + } ); + + it( 'should set `linkHref` attribute to allowed elements on multi-selection', () => { + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + + setData( model, '[][]' ); + + command.execute( 'url' ); + + expect( getData( model ) ).to.equal( + '' + + '[][]' + + '' + ); + } ); } ); } ); @@ -380,24 +585,24 @@ describe( 'LinkCommand', () => { it( 'should not insert text with `linkHref` attribute when is not allowed in parent', () => { model.schema.addAttributeCheck( ( ctx, attributeName ) => { - if ( ctx.endsWith( 'p $text' ) && attributeName == 'linkHref' ) { + if ( ctx.endsWith( 'paragraph $text' ) && attributeName == 'linkHref' ) { return false; } } ); - setData( model, '

foo[]bar

' ); + setData( model, 'foo[]bar' ); command.execute( 'url' ); - expect( getData( model ) ).to.equal( '

foo[]bar

' ); + expect( getData( model ) ).to.equal( 'foo[]bar' ); } ); it( 'should not insert text node if link is empty', () => { - setData( model, '

foo[]bar

' ); + setData( model, 'foo[]bar' ); command.execute( '' ); - expect( getData( model ) ).to.equal( '

foo[]bar

' ); + expect( getData( model ) ).to.equal( 'foo[]bar' ); } ); // https://github.com/ckeditor/ckeditor5/issues/8210 @@ -450,14 +655,21 @@ describe( 'LinkCommand', () => { allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar', 'linkIsSth' ] } ); - model.schema.register( 'image', { + model.schema.register( 'linkableBlock', { allowIn: '$root', isObject: true, isBlock: true, allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar', 'linkIsSth' ] } ); - model.schema.register( 'p', { inheritAllFrom: '$block' } ); + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar', 'linkIsSth' ] + } ); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); } ); } ); @@ -519,6 +731,27 @@ describe( 'LinkCommand', () => { expect( getData( model ) ).to.equal( 'foo[<$text linkHref="url">url]bar' ); } ); + + it( 'should insert additional attributes to a linkable block when it is created', () => { + setData( model, '[]' ); + + command.execute( 'url', { linkIsFoo: true, linkIsBar: true, linkIsSth: true } ); + + expect( getData( model ) ).to + .equal( '[]' ); + } ); + + it( 'should insert additional attributes to a linkable inline element when it is created', () => { + setData( model, 'foo[]bar' ); + + command.execute( 'url', { linkIsFoo: true, linkIsBar: true, linkIsSth: true } ); + + expect( getData( model ) ).to.equal( + '' + + 'foo[]bar' + + '' + ); + } ); } ); describe( 'restoreManualDecoratorStates()', () => { @@ -583,8 +816,15 @@ describe( 'LinkCommand', () => { expect( command._getDecoratorStateFromModel( 'linkIsBar' ) ).to.be.true; } ); - it( 'obtain current values from the image element', () => { - setData( model, '[]' ); + it( 'obtain current values from the linkable block element', () => { + setData( model, '[]' ); + + expect( command._getDecoratorStateFromModel( 'linkIsFoo' ) ).to.be.undefined; + expect( command._getDecoratorStateFromModel( 'linkIsBar' ) ).to.be.true; + } ); + + it( 'obtain current values from the linkable inline element', () => { + setData( model, '[]' ); expect( command._getDecoratorStateFromModel( 'linkIsFoo' ) ).to.be.undefined; expect( command._getDecoratorStateFromModel( 'linkIsBar' ) ).to.be.true; diff --git a/packages/ckeditor5-link/tests/linkimageediting.js b/packages/ckeditor5-link/tests/linkimageediting.js index 5a68f0313c1..2736cd610d0 100644 --- a/packages/ckeditor5-link/tests/linkimageediting.js +++ b/packages/ckeditor5-link/tests/linkimageediting.js @@ -13,6 +13,7 @@ import ImageBlockEditing from '@ckeditor/ckeditor5-image/src/image/imageblockedi import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlineediting'; import LinkImageEditing from '../src/linkimageediting'; +import LinkEditing from '../src/linkediting'; describe( 'LinkImageEditing', () => { let editor, model, view; @@ -42,14 +43,22 @@ describe( 'LinkImageEditing', () => { } ); it( 'should set proper schema rules for image style when ImageBlock plugin is enabled', async () => { - const newEditor = await VirtualTestEditor.create( { plugins: [ ImageBlockEditing, LinkImageEditing ] } ); + const newEditor = await VirtualTestEditor.create( { + plugins: [ ImageBlockEditing, LinkImageEditing ] + } ); + expect( newEditor.model.schema.checkAttribute( [ '$root', 'image' ], 'linkHref' ) ).to.be.true; + await newEditor.destroy(); } ); it( 'should set proper schema rules for image style when ImageInline plugin is enabled', async () => { - const newEditor = await VirtualTestEditor.create( { plugins: [ ImageInlineEditing, LinkImageEditing ] } ); + const newEditor = await VirtualTestEditor.create( { + plugins: [ ImageInlineEditing, LinkImageEditing ] + } ); + expect( newEditor.model.schema.checkAttribute( [ '$root', 'imageInline' ], 'linkHref' ) ).to.be.true; + await newEditor.destroy(); } ); @@ -57,24 +66,15 @@ describe( 'LinkImageEditing', () => { expect( LinkImageEditing.requires ).to.include( 'ImageEditing' ); } ); - describe( 'conversion in data pipeline', () => { - describe( 'model to view', () => { - it( 'should attach a link indicator to the image element', () => { - setModelData( model, 'alt text' ); + it( 'should require ImageUtils by name', () => { + expect( LinkImageEditing.requires ).to.include( 'ImageUtils' ); + } ); - expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.match( new RegExp( - '
' + - '' + - 'alt text' + - '' + - ']+>.*<\\/svg>' + - '' + - '' + - '
' - ) ); - } ); - } ); + it( 'should require LinkEditing', () => { + expect( LinkImageEditing.requires ).to.include( LinkEditing ); + } ); + describe( 'conversion in data pipeline', () => { describe( 'model to data', () => { it( 'should convert an image with a link', () => { setModelData( model, 'alt text' ); @@ -108,6 +108,27 @@ describe( 'LinkImageEditing', () => { '
' ); } ); + + it( 'should convert a link containing an inline image as a single anchor element in data', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, ImageInlineEditing, LinkImageEditing ] + } ); + const model = editor.model; + + setModelData( model, + '' + + '<$text linkhref="http://ckeditor.com">foo ' + + '' + + '<$text linkhref="http://ckeditor.com"> bar' + + '' + ); + + expect( editor.getData() ).to.equal( + '

foo alt textbar

' + ); + + return editor.destroy(); + } ); } ); describe( 'view to model', () => { @@ -190,7 +211,7 @@ describe( 'LinkImageEditing', () => { } ); describe( 'a > img', () => { - it( 'should convert a link in an image figure', () => { + it( 'should convert an image surrounded by a link', () => { editor.setData( 'alt text' ); @@ -199,14 +220,14 @@ describe( 'LinkImageEditing', () => { .to.equal( 'alt text' ); } ); - it( 'should convert an image with a link and without alt attribute', () => { + it( 'should convert an image surrounded by a link without alt attribute', () => { editor.setData( '' ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( '' ); } ); - it( 'should not convert without src attribute', () => { + it( 'should not convert an image surrounded by a link without src attribute', () => { editor.setData( 'alt text' ); expect( getModelData( model, { withoutSelection: true } ) ) @@ -254,6 +275,27 @@ describe( 'LinkImageEditing', () => { expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( 'alt text' ); } ); + + it( 'should not convert an image surrounded by a link to a linked block image' + + 'when the ImageInline plugin is loaded', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, ImageBlockEditing, ImageInlineEditing, LinkImageEditing ] + } ); + const model = editor.model; + + editor.setData( + 'alt text' + ); + + // If ImageInline is loaded, then ☝️ should be a plain linked inline image in the editor. + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + '' + + '' + ); + + await editor.destroy(); + } ); } ); describe( 'figure > a > img + figcaption', () => { @@ -296,8 +338,6 @@ describe( 'LinkImageEditing', () => { '
' + '' + 'alt text' + - // Content of the UIElement is skipped here. - '' + '' + '
' ); @@ -315,8 +355,6 @@ describe( 'LinkImageEditing', () => { '
' + '' + 'alt text' + - // Content of the UIElement is skipped here. - '' + '' + '
' ); @@ -336,6 +374,31 @@ describe( 'LinkImageEditing', () => { '
' ); } ); + + it( 'should link a text including an inline image as a single anchor element', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, ImageInlineEditing, LinkImageEditing ] + } ); + const model = editor.model; + + setModelData( model, + '[foobar]' + ); + + editor.execute( 'link', 'https://cksource.com' ); + + expect( getViewData( editor.editing.view ) ).to.equal( + '

' + + '[' + + 'foo' + + 'alt text' + + 'bar' + + ']' + + '

' + ); + + return editor.destroy(); + } ); } ); describe( 'figure > a > img + span + figcaption', () => { @@ -355,8 +418,6 @@ describe( 'LinkImageEditing', () => { '
' + '' + 'alt text' + - // Content of the UIElement is skipped here. - '' + '' + '
' + diff --git a/packages/ckeditor5-link/tests/linkimageui.js b/packages/ckeditor5-link/tests/linkimageui.js index fb92cd47709..1a7a4777c94 100644 --- a/packages/ckeditor5-link/tests/linkimageui.js +++ b/packages/ckeditor5-link/tests/linkimageui.js @@ -101,52 +101,108 @@ describe( 'LinkImageUI', () => { } ); describe( 'click', () => { - it( 'should prevent default behavior if image is wrapped with a link', () => { + it( 'should prevent default behavior to prevent navigation if a block image has a link', () => { editor.setData( '
' ); - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); + editor.model.change( writer => { + writer.setSelection( editor.model.document.getRoot(), 'in' ); } ); - const img = viewDocument.selection.getSelectedElement(); + const imageWidget = viewDocument.selection.getSelectedElement(); const data = fakeEventData(); - const eventInfo = new EventInfo( img, 'click' ); + const eventInfo = new EventInfo( imageWidget, 'click' ); const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); viewDocument.fire( 'click', domEventDataMock ); - expect( img.getChild( 0 ).name ).to.equal( 'a' ); + expect( imageWidget.getChild( 0 ).name ).to.equal( 'a' ); + expect( data.preventDefault.called ).to.be.true; + } ); + + it( 'should prevent default behavior to prevent navigation if an inline image is wrapped in a link', () => { + editor.setData( '

' ); + + editor.model.change( writer => { + writer.setSelection( editor.model.document.getRoot().getChild( 0 ), 'in' ); + } ); + + const imageWidget = viewDocument.selection.getSelectedElement().getChild( 0 ); + const data = fakeEventData(); + const eventInfo = new EventInfo( imageWidget, 'click' ); + const domEventDataMock = new DomEventData( viewDocument, eventInfo, data ); + + viewDocument.fire( 'click', domEventDataMock ); + + expect( imageWidget.getChild( 0 ).name ).to.equal( 'img' ); expect( data.preventDefault.called ).to.be.true; } ); } ); describe( 'event handling', () => { - it( 'should show plugin#actionsView after "execute" if image is already linked', () => { - const linkUIPlugin = editor.plugins.get( 'LinkUI' ); + let root; - editor.setData( '
' ); + beforeEach( () => { + root = editor.model.document.getRoot(); + } ); + + describe( 'when a block image is selected', () => { + it( 'should show plugin#actionsView after "execute" if an image is already linked', () => { + const linkUIPlugin = editor.plugins.get( 'LinkUI' ); + + editor.setData( '
' ); + + editor.model.change( writer => { + writer.setSelection( root.getChild( 0 ), 'on' ); + } ); - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); + linkButton.fire( 'execute' ); + + expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.actionsView ); } ); - linkButton.fire( 'execute' ); + it( 'should show plugin#formView after "execute" if image is not linked', () => { + const linkUIPlugin = editor.plugins.get( 'LinkUI' ); + + editor.setData( '
' ); + + editor.model.change( writer => { + writer.setSelection( root.getChild( 0 ), 'on' ); + } ); - expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.actionsView ); + linkButton.fire( 'execute' ); + + expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.formView ); + } ); } ); - it( 'should show plugin#formView after "execute" if image is not linked', () => { - const linkUIPlugin = editor.plugins.get( 'LinkUI' ); + describe( 'when an inline image is selected', () => { + it( 'should show plugin#actionsView after "execute" if an image is already linked', () => { + const linkUIPlugin = editor.plugins.get( 'LinkUI' ); + + editor.setData( '

' ); - editor.setData( '
' ); + editor.model.change( writer => { + writer.setSelection( root.getChild( 0 ), 'in' ); + } ); - editor.editing.view.change( writer => { - writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' ); + linkButton.fire( 'execute' ); + + expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.actionsView ); } ); - linkButton.fire( 'execute' ); + it( 'should show plugin#formView after "execute" if image is not linked', () => { + const linkUIPlugin = editor.plugins.get( 'LinkUI' ); + + editor.setData( '

' ); + + editor.model.change( writer => { + writer.setSelection( root.getChild( 0 ), 'in' ); + } ); - expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.formView ); + linkButton.fire( 'execute' ); + + expect( linkUIPlugin._balloon.visibleView ).to.equals( linkUIPlugin.formView ); + } ); } ); } ); } ); diff --git a/packages/ckeditor5-link/tests/linkui.js b/packages/ckeditor5-link/tests/linkui.js index b467aed8111..03b1080c32c 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -6,6 +6,7 @@ /* globals document, Event */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; + import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import indexOf from '@ckeditor/ckeditor5-utils/src/dom/indexof'; import isRange from '@ckeditor/ckeditor5-utils/src/dom/isrange'; @@ -13,18 +14,18 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; import env from '@ckeditor/ckeditor5-utils/src/env'; - import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; -import LinkEditing from '../src/linkediting'; -import LinkUI from '../src/linkui'; -import LinkFormView from '../src/ui/linkformview'; -import LinkActionsView from '../src/ui/linkactionsview'; +import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver'; import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import View from '@ckeditor/ckeditor5-ui/src/view'; +import { toWidget } from '@ckeditor/ckeditor5-widget'; -import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver'; +import LinkEditing from '../src/linkediting'; +import LinkUI from '../src/linkui'; +import LinkFormView from '../src/ui/linkformview'; +import LinkActionsView from '../src/ui/linkactionsview'; describe( 'LinkUI', () => { let editor, linkUIFeature, linkButton, balloon, formView, actionsView, editorElement; @@ -179,12 +180,12 @@ describe( 'LinkUI', () => { linkUIFeature._showUI(); expect( balloon.visibleView ).to.equal( actionsView ); - sinon.assert.calledWithExactly( balloonAddSpy, { - view: actionsView, - position: { - target: linkElement - } - } ); + + const addSpyCallArgs = balloonAddSpy.firstCall.args[ 0 ]; + + expect( addSpyCallArgs.view ).to.equal( actionsView ); + expect( addSpyCallArgs.position.target ).to.be.a( 'function' ); + expect( addSpyCallArgs.position.target() ).to.equal( linkElement ); } ); // #https://github.com/ckeditor/ckeditor5-link/issues/181 @@ -195,22 +196,20 @@ describe( 'LinkUI', () => { linkUIFeature._showUI(); expect( balloon.visibleView ).to.equal( actionsView ); - sinon.assert.calledWithExactly( balloonAddSpy, { - view: actionsView, - position: { - target: linkElement - } - } ); + + const addSpyFirstCallArgs = balloonAddSpy.firstCall.args[ 0 ]; + + expect( addSpyFirstCallArgs.view ).to.equal( actionsView ); + expect( addSpyFirstCallArgs.position.target ).to.be.a( 'function' ); + expect( addSpyFirstCallArgs.position.target() ).to.equal( linkElement ); linkUIFeature._showUI(); - expect( balloon.visibleView ).to.equal( formView ); - sinon.assert.calledWithExactly( balloonAddSpy, { - view: formView, - position: { - target: linkElement - } - } ); + const addSpyCallSecondCallArgs = balloonAddSpy.secondCall.args[ 0 ]; + + expect( addSpyCallSecondCallArgs.view ).to.equal( formView ); + expect( addSpyCallSecondCallArgs.position.target ).to.be.a( 'function' ); + expect( addSpyCallSecondCallArgs.position.target() ).to.equal( linkElement ); } ); it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => { @@ -283,7 +282,7 @@ describe( 'LinkUI', () => { const linkDomElement = editor.editing.view.domConverter.mapViewToDom( linkViewElement ); expect( balloon.visibleView ).to.equal( actionsView ); - expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.equal( linkDomElement ); + expect( balloon.view.pin.lastCall.args[ 0 ].target() ).to.equal( linkDomElement ); balloon.add( { stackId: 'custom', @@ -301,12 +300,12 @@ describe( 'LinkUI', () => { expect( balloon.visibleView ).to.equal( actionsView ); expect( balloon.hasView( customView ) ).to.equal( true ); - expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.not.equal( linkDomElement ); + expect( balloon.view.pin.lastCall.args[ 0 ].target() ).to.not.equal( linkDomElement ); const newLinkViewElement = editor.editing.view.document.getRoot().getChild( 0 ).getChild( 0 ).getChild( 1 ); const newLinkDomElement = editor.editing.view.domConverter.mapViewToDom( newLinkViewElement ); - expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.equal( newLinkDomElement ); + expect( balloon.view.pin.lastCall.args[ 0 ].target() ).to.equal( newLinkDomElement ); } ); describe( 'response to ui#update', () => { @@ -352,9 +351,8 @@ describe( 'LinkUI', () => { } ); sinon.assert.calledOnce( spy ); - sinon.assert.calledWithExactly( spy, { - target: view.domConverter.mapViewToDom( linkElement ) - } ); + + expect( spy.firstCall.args[ 0 ].target() ).to.equal( view.domConverter.mapViewToDom( linkElement ) ); } ); // https://github.com/ckeditor/ckeditor5-link/issues/113 @@ -1080,6 +1078,29 @@ describe( 'LinkUI', () => { observer.fire( 'click', { target: {} } ); sinon.assert.notCalled( spy ); } ); + + it( 'should do nothing when the selection spans over a link which only child is a widget', () => { + editor.model.schema.register( 'inlineWidget', { + allowWhere: '$text', + isObject: true, + isInline: true + } ); + + editor.conversion.for( 'downcast' ) + .elementToElement( { + model: 'inlineWidget', + view: ( modelItem, { writer } ) => toWidget( + writer.createContainerElement( 'inlineWidget' ), + writer, + { label: 'inline widget' } + ) + } ); + + setModelData( editor.model, '[]' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.notCalled( spy ); + } ); } ); } ); diff --git a/packages/ckeditor5-link/tests/manual/linkimage.html b/packages/ckeditor5-link/tests/manual/linkimage.html index b6c62c0844f..17265bddc41 100644 --- a/packages/ckeditor5-link/tests/manual/linkimage.html +++ b/packages/ckeditor5-link/tests/manual/linkimage.html @@ -1,9 +1,61 @@
-
- - bar - -
CKEditor logo - caption
+

Inline playground

+

+ Inline images that are Linked inline image + both Another linked inline image linked + and not Unlinked inline image linked. +

+ +

Block playground

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
 Linked 
yesno
Captionedyes +
+ + Linked captioned block image + +
Linked captioned block image
+
+
+
+ Unlinked captioned block image +
 Captioned block image
+
+
no +
+ + Linked block image + +
+
+
+ Unlinked block image +
+
- bar
diff --git a/packages/ckeditor5-link/tests/manual/linkimage.js b/packages/ckeditor5-link/tests/manual/linkimage.js index b4b0d29c0ff..0088b06d995 100644 --- a/packages/ckeditor5-link/tests/manual/linkimage.js +++ b/packages/ckeditor5-link/tests/manual/linkimage.js @@ -7,11 +7,24 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload'; import LinkImage from '../../src/linkimage'; +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + ClassicEditor .create( document.querySelector( '#editor' ), { - plugins: [ ArticlePluginSet, LinkImage ], + cloudServices: CS_CONFIG, + + plugins: [ + ArticlePluginSet, + LinkImage, + CloudServices, + ImageUpload, + EasyImage + ], toolbar: [ 'heading', '|', @@ -24,6 +37,7 @@ ClassicEditor 'outdent', 'indent', '|', + 'uploadImage', 'blockQuote', 'insertTable', 'mediaEmbed', diff --git a/packages/ckeditor5-link/tests/manual/sample50.png b/packages/ckeditor5-link/tests/manual/sample50.png new file mode 100644 index 00000000000..c3813c9319e Binary files /dev/null and b/packages/ckeditor5-link/tests/manual/sample50.png differ diff --git a/packages/ckeditor5-link/tests/unlinkcommand.js b/packages/ckeditor5-link/tests/unlinkcommand.js index 206f7655690..e98e3c794d6 100644 --- a/packages/ckeditor5-link/tests/unlinkcommand.js +++ b/packages/ckeditor5-link/tests/unlinkcommand.js @@ -22,12 +22,11 @@ describe( 'UnlinkCommand', () => { document = model.document; command = new UnlinkCommand( editor ); + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); model.schema.extend( '$text', { - allowIn: '$root', + allowIn: [ '$root', 'paragraph' ], allowAttributes: 'linkHref' } ); - - model.schema.register( 'p', { inheritAllFrom: '$block' } ); } ); } ); @@ -52,7 +51,7 @@ describe( 'UnlinkCommand', () => { expect( command.isEnabled ).to.false; } ); - describe( 'for images', () => { + describe( 'for block images', () => { beforeEach( () => { model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } ); } ); @@ -101,6 +100,55 @@ describe( 'UnlinkCommand', () => { expect( command.isEnabled ).to.be.false; } ); } ); + + describe( 'for inline images', () => { + beforeEach( () => { + model.schema.register( 'imageInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + } ); + + it( 'should be true when a linked inline image is selected', () => { + setData( model, '[]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when a linked inline image and a text are selected', () => { + setData( model, '[Foo]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when a text and a linked inline image are selected', () => { + setData( model, '[Foo]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when two linked inline images are selected', () => { + setData( model, + '[]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if an inline image does not accept the `linkHref` attribute in given context', () => { + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + if ( ctx.endsWith( 'paragraph imageInline' ) && attributeName == 'linkHref' ) { + return false; + } + } ); + + setData( model, '[]' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); } ); describe( 'execute()', () => { @@ -146,17 +194,17 @@ describe( 'UnlinkCommand', () => { it( 'should remove `linkHref` attribute from multiple blocks', () => { setData( model, - '

<$text linkHref="url">fo[oo

' + - '

<$text linkHref="url">123

' + - '

<$text linkHref="url">baa]ar

' + '<$text linkHref="url">fo[oo' + + '<$text linkHref="url">123' + + '<$text linkHref="url">baa]ar' ); command.execute(); expect( getData( model ) ).to.equal( - '

<$text linkHref="url">fo[oo

' + - '

123

' + - '

baa]<$text linkHref="url">ar

' + '<$text linkHref="url">fo[oo' + + '123' + + 'baa]<$text linkHref="url">ar' ); } ); @@ -167,6 +215,91 @@ describe( 'UnlinkCommand', () => { expect( document.selection.hasAttribute( 'linkHref' ) ).to.false; } ); + + describe( 'for block elements allowing linkHref', () => { + beforeEach( () => { + model.schema.register( 'image', { isBlock: true, allowWhere: '$text', allowAttributes: [ 'linkHref' ] } ); + } ); + + it( 'should remove the linkHref attribute when a linked block is selected', () => { + setData( model, '[]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[]' ); + } ); + + it( 'should remove the linkHref attribute when a linked block and text are selected', () => { + setData( model, '[Foo]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[Foo]' ); + } ); + + it( 'should remove the linkHref attribute when a text and a linked block are selected', () => { + setData( model, '[Foo]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[Foo]' ); + } ); + + it( 'should remove the linkHref attribute when two linked blocks are selected', () => { + setData( model, '[]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[]' ); + } ); + } ); + + describe( 'for inline elements allowing linkHref', () => { + beforeEach( () => { + model.schema.register( 'imageInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + } ); + + it( 'should be true when a linked inline element is selected', () => { + setData( model, '[]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[]' ); + } ); + + it( 'should be true when a linked inline element and a text are selected', () => { + setData( model, '[Foo]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[Foo]' ); + } ); + + it( 'should be true when a text and a linked inline element are selected', () => { + setData( model, '[Foo]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[Foo]' ); + } ); + + it( 'should be true when two linked inline element are selected', () => { + setData( model, + '[]' + ); + + command.execute(); + + expect( getData( model ) ).to.equal( + '[]' + ); + } ); + } ); } ); describe( 'collapsed selection', () => { @@ -245,17 +378,17 @@ describe( 'UnlinkCommand', () => { it( 'should remove `linkHref` attribute from selection siblings only in the same parent as selection parent', () => { setData( model, - '

<$text linkHref="url">bar

' + - '

<$text linkHref="url">fo[]o

' + - '

<$text linkHref="url">bar

' + '<$text linkHref="url">bar' + + '<$text linkHref="url">fo[]o' + + '<$text linkHref="url">bar' ); command.execute(); expect( getData( model ) ).to.equal( - '

<$text linkHref="url">bar

' + - '

fo[]o

' + - '

<$text linkHref="url">bar

' + '<$text linkHref="url">bar' + + 'fo[]o' + + '<$text linkHref="url">bar' ); } ); @@ -330,7 +463,20 @@ describe( 'UnlinkCommand', () => { allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar' ] } ); - model.schema.register( 'p', { inheritAllFrom: '$block' } ); + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + + model.schema.register( 'linkableBlock', { + isBlock: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); + + model.schema.register( 'linkableInline', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributes: [ 'linkHref' ] + } ); } ); } ); @@ -345,5 +491,21 @@ describe( 'UnlinkCommand', () => { expect( getData( model ) ).to.equal( 'f[]oobar' ); } ); + + it( 'should remove manual decorators from linkable blocks together with linkHref', () => { + setData( model, '[]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[]' ); + } ); + + it( 'should remove manual decorators from linkable inline elements together with linkHref', () => { + setData( model, '[]' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '[]' ); + } ); } ); } ); diff --git a/packages/ckeditor5-link/tests/utils.js b/packages/ckeditor5-link/tests/utils.js index 3e59bc9b70d..b057d364439 100644 --- a/packages/ckeditor5-link/tests/utils.js +++ b/packages/ckeditor5-link/tests/utils.js @@ -11,7 +11,13 @@ import Text from '@ckeditor/ckeditor5-engine/src/view/text'; import Schema from '@ckeditor/ckeditor5-engine/src/model/schema'; import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; import { - createLinkElement, isLinkElement, ensureSafeUrl, normalizeDecorators, isImageAllowed, isEmail, addLinkProtocolIfApplicable + createLinkElement, + isLinkElement, + ensureSafeUrl, + normalizeDecorators, + isLinkableElement, + isEmail, + addLinkProtocolIfApplicable } from '../src/utils'; describe( 'utils', () => { @@ -221,22 +227,27 @@ describe( 'utils', () => { } ); } ); - describe( 'isImageAllowed()', () => { + describe( 'isLinkableElement()', () => { it( 'returns false when passed "null" as element', () => { - expect( isImageAllowed( null, new Schema() ) ).to.equal( false ); + expect( isLinkableElement( null, new Schema() ) ).to.equal( false ); } ); it( 'returns false when passed an element that is not the image element', () => { const element = new ModelElement( 'paragraph' ); - expect( isImageAllowed( element, new Schema() ) ).to.equal( false ); + expect( isLinkableElement( element, new Schema() ) ).to.equal( false ); } ); - it( 'returns false when schema does not allow linking images', () => { + it( 'returns false when schema does not allow linking images (block image)', () => { const element = new ModelElement( 'image' ); - expect( isImageAllowed( element, new Schema() ) ).to.equal( false ); + expect( isLinkableElement( element, new Schema() ) ).to.equal( false ); } ); - it( 'returns true when passed an image element and it can be linked', () => { + it( 'returns false when schema does not allow linking images (inline image)', () => { + const element = new ModelElement( 'imageInline' ); + expect( isLinkableElement( element, new Schema() ) ).to.equal( false ); + } ); + + it( 'returns true when passed a block image element and it can be linked', () => { const element = new ModelElement( 'image' ); const schema = new Schema(); @@ -245,7 +256,19 @@ describe( 'utils', () => { allowAttributes: [ 'linkHref' ] } ); - expect( isImageAllowed( element, schema ) ).to.equal( true ); + expect( isLinkableElement( element, schema ) ).to.equal( true ); + } ); + + it( 'returns true when passed an inline image element and it can be linked', () => { + const element = new ModelElement( 'imageInline' ); + const schema = new Schema(); + + schema.register( 'imageInline', { + allowIn: '$root', + allowAttributes: [ 'linkHref' ] + } ); + + expect( isLinkableElement( element, schema ) ).to.equal( true ); } ); } ); diff --git a/packages/ckeditor5-link/theme/linkimage.css b/packages/ckeditor5-link/theme/linkimage.css index 841a0290c53..5facf051f89 100644 --- a/packages/ckeditor5-link/theme/linkimage.css +++ b/packages/ckeditor5-link/theme/linkimage.css @@ -3,17 +3,14 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -.ck.ck-link-image_icon { - position: absolute; - top: var(--ck-spacing-medium); - right: var(--ck-spacing-medium); - width: 28px; - height: 28px; - padding: 4px; - box-sizing: border-box; - border-radius: var(--ck-border-radius); - - & svg { - fill: currentColor; +.ck.ck-editor__editable { + /* Linked image indicator */ + & figure.image > a, + & a span.image-inline { + &::after { + display: block; + position: absolute; + } } } + diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageuploadicon.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageuploadicon.css index 915e5b2c891..35a22bc2c8d 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageuploadicon.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageuploadicon.css @@ -7,6 +7,7 @@ --ck-color-image-upload-icon: hsl(0, 0%, 100%); --ck-color-image-upload-icon-background: hsl(120, 100%, 27%); + /* Match the icon size with the linked image indicator brought by the link image feature. */ --ck-image-upload-icon-size: 20; --ck-image-upload-icon-width: 2px; --ck-image-upload-icon-is-visible: clamp(0px, 100% - 50px, 1px); diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/link.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/link.css index 0bd690eb8d3..6b3c8d3bdfe 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/link.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/link.css @@ -6,6 +6,11 @@ /* Class added to span element surrounding currently selected link. */ .ck .ck-link_selected { background: var(--ck-color-link-selected-background); + + /* Give linked inline images some outline to let the user know they are also part of the link. */ + & span.image-inline { + outline: var(--ck-widget-outline-thickness) solid var(--ck-color-link-selected-background); + } } /* diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkimage.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkimage.css index b1204ea33da..6bfdc286fd0 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkimage.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkimage.css @@ -3,7 +3,41 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -.ck.ck-link-image_icon { - color: hsl(0, 0%, 100%); - background: hsla(0, 0%, 0%, .4); +:root { + /* Match the icon size with the upload indicator brought by the image upload feature. */ + --ck-link-image-indicator-icon-size: 20; + --ck-link-image-indicator-icon-is-visible: clamp(0px, 100% - 50px, 1px); } + +.ck.ck-editor__editable { + /* Linked image indicator */ + & figure.image > a, + & a span.image-inline { + &::after { + content: ""; + + /* + * Smaller images should have the icon closer to the border. + * Match the icon position with the upload indicator brought by the image upload feature. + */ + top: min(var(--ck-spacing-medium), 6%); + right: min(var(--ck-spacing-medium), 6%); + + background-color: hsla(0, 0%, 0%, .4); + background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjAgMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZmlsbD0iI2ZmZiIgZD0ibTExLjA3NyAxNSAuOTkxLTEuNDE2YS43NS43NSAwIDEgMSAxLjIyOS44NmwtMS4xNDggMS42NGEuNzQ4Ljc0OCAwIDAgMS0uMjE3LjIwNiA1LjI1MSA1LjI1MSAwIDAgMS04LjUwMy01Ljk1NS43NDEuNzQxIDAgMCAxIC4xMi0uMjc0bDEuMTQ3LTEuNjM5YS43NS43NSAwIDEgMSAxLjIyOC44Nkw0LjkzMyAxMC43bC4wMDYuMDAzYTMuNzUgMy43NSAwIDAgMCA2LjEzMiA0LjI5NGwuMDA2LjAwNHptNS40OTQtNS4zMzVhLjc0OC43NDggMCAwIDEtLjEyLjI3NGwtMS4xNDcgMS42MzlhLjc1Ljc1IDAgMSAxLTEuMjI4LS44NmwuODYtMS4yM2EzLjc1IDMuNzUgMCAwIDAtNi4xNDQtNC4zMDFsLS44NiAxLjIyOWEuNzUuNzUgMCAwIDEtMS4yMjktLjg2bDEuMTQ4LTEuNjRhLjc0OC43NDggMCAwIDEgLjIxNy0uMjA2IDUuMjUxIDUuMjUxIDAgMCAxIDguNTAzIDUuOTU1em0tNC41NjMtMi41MzJhLjc1Ljc1IDAgMCAxIC4xODQgMS4wNDVsLTMuMTU1IDQuNTA1YS43NS43NSAwIDEgMS0xLjIyOS0uODZsMy4xNTUtNC41MDZhLjc1Ljc1IDAgMCAxIDEuMDQ1LS4xODR6Ii8+PC9zdmc+"); + background-size: 14px; + background-repeat: no-repeat; + background-position: center; + border-radius: 100%; + + /* + * Use CSS math to simulate container queries. + * https://css-tricks.com/the-raven-technique-one-step-closer-to-container-queries/#what-about-showing-and-hiding-things + */ + overflow: hidden; + width: calc(var(--ck-link-image-indicator-icon-is-visible) * var(--ck-link-image-indicator-icon-size)); + height: calc(var(--ck-link-image-indicator-icon-is-visible) * var(--ck-link-image-indicator-icon-size)); + } + } +} +