Skip to content

Commit

Permalink
Merge pull request #7386 from ckeditor/i/7330
Browse files Browse the repository at this point in the history
Feature (link): Introduced the linking images feature. Closes #7330.
  • Loading branch information
jodator committed Jun 10, 2020
2 parents 48d80cb + dc314d7 commit cc0e694
Show file tree
Hide file tree
Showing 15 changed files with 843 additions and 7 deletions.
14 changes: 13 additions & 1 deletion packages/ckeditor5-image/src/image/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,23 @@ export function isImageAllowed( model ) {
* 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 `<img>` can be wrapped to other elements, e.g. `<a>`. Nested check required.
*
* @param {module:engine/view/element~Element} figureView
* @returns {module:engine/view/element~Element}
*/
export function getViewImgFromWidget( figureView ) {
return Array.from( figureView.getChildren() ).find( viewChild => viewChild.is( 'img' ) );
const figureChildren = [];

for ( const figureChild of figureView.getChildren() ) {
figureChildren.push( figureChild );

if ( figureChild.is( 'element' ) ) {
figureChildren.push( ...figureChild.getChildren() );
}
}

return figureChildren.find( viewChild => viewChild.is( 'img' ) );
}

// Checks if image is allowed by schema in optimal insertion parent.
Expand Down
57 changes: 55 additions & 2 deletions packages/ckeditor5-image/tests/image/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,27 @@ import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfr
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 { toImageWidget, isImageWidget, getSelectedImageWidget, isImage, isImageAllowed, insertImage } from '../../src/image/utils';
import {
toImageWidget,
isImageWidget,
getSelectedImageWidget,
isImage,
isImageAllowed,
insertImage,
getViewImgFromWidget
} from '../../src/image/utils';
import { isWidget, getLabel } from '@ckeditor/ckeditor5-widget/src/utils';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
import Image from '../../src/image/imageediting';
import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap';

describe( 'image widget utils', () => {
let element, image, writer, viewDocument;

beforeEach( () => {
viewDocument = new ViewDocument();
viewDocument = new ViewDocument( new StylesProcessor() );
writer = new ViewDowncastWriter( viewDocument );
image = writer.createContainerElement( 'img' );
element = writer.createContainerElement( 'figure' );
Expand Down Expand Up @@ -266,4 +275,48 @@ describe( 'image widget utils', () => {
expect( getModelData( model ) ).to.equal( '<other>[]</other>' );
} );
} );

describe( 'getViewImgFromWidget()', () => {
// figure
// img
it( 'returns the the img element from widget if the img is the first children', () => {
expect( getViewImgFromWidget( 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( getViewImgFromWidget( 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( getViewImgFromWidget( 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( getViewImgFromWidget( element ) ).to.equal( image );
} );
} );
} );
1 change: 1 addition & 0 deletions packages/ckeditor5-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@ckeditor/ckeditor5-clipboard": "^19.0.1",
"@ckeditor/ckeditor5-editor-classic": "^19.0.1",
"@ckeditor/ckeditor5-enter": "^19.0.1",
"@ckeditor/ckeditor5-image": "^19.0.1",
"@ckeditor/ckeditor5-paragraph": "^19.1.0",
"@ckeditor/ckeditor5-theme-lark": "^19.1.0",
"@ckeditor/ckeditor5-typing": "^19.0.1",
Expand Down
41 changes: 40 additions & 1 deletion packages/ckeditor5-link/src/linkcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,30 @@ export default class LinkCommand extends Command {
}
} else {
// If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
// omitting nodes where `linkHref` attribute is disallowed.
// omitting nodes where the `linkHref` attribute is disallowed.
const ranges = model.schema.getValidRanges( selection.getRanges(), 'linkHref' );

// But for the first, check whether the `linkHref` attribute is allowed on selected blocks (e.g. the "image" element).
const allowedRanges = [];

for ( const element of selection.getSelectedBlocks() ) {
if ( model.schema.checkAttribute( element, 'linkHref' ) ) {
allowedRanges.push( writer.createRangeOn( element ) );
}
}

// Ranges that accept the `linkHref` attribute. Since we will iterate over `allowedRanges`, let's clone it.
const rangesToUpdate = allowedRanges.slice();

// For all selection ranges we want to check whether given range is inside an element that accepts the `linkHref` attribute.
// If so, we don't want to propagate applying the attribute to its children.
for ( const range of ranges ) {
if ( this._isRangeToUpdate( range, allowedRanges ) ) {
rangesToUpdate.push( range );
}
}

for ( const range of rangesToUpdate ) {
writer.setAttribute( 'linkHref', href, range );

truthyManualDecorators.forEach( item => {
Expand All @@ -216,4 +236,23 @@ export default class LinkCommand extends Command {
const doc = this.editor.model.document;
return doc.selection.getAttribute( decoratorName );
}

/**
* Checks whether specified `range` is inside an element that accepts the `linkHref` attribute.
*
* @private
* @param {module:engine/view/range~Range} range A range to check.
* @param {Array.<module:engine/view/range~Range>} allowedRanges An array of ranges created on elements where the attribute is accepted.
* @returns {Boolean}
*/
_isRangeToUpdate( range, allowedRanges ) {
for ( const allowedRange of allowedRanges ) {
// A range is inside an element that will have the `linkHref` attribute. Do not modify its nodes.
if ( allowedRange.containsRange( range ) ) {
return false;
}
}

return true;
}
}
36 changes: 36 additions & 0 deletions packages/ckeditor5-link/src/linkimage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/**
* @module link/linkimage
*/

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import LinkImageEditing from './linkimageediting';
import LinkImageUI from './linkimageui';

/**
* The `LinkImage` plugin.
*
* This is a "glue" plugin that loads the {@link module:link/linkimageediting~LinkImageEditing link image editing feature}
* and {@link module:link/linkimageui~LinkImageUI linkimage UI feature}.
*
* @extends module:core/plugin~Plugin
*/
export default class LinkImage extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ LinkImageEditing, LinkImageUI ];
}

/**
* @inheritDoc
*/
static get pluginName() {
return 'LinkImage';
}
}
141 changes: 141 additions & 0 deletions packages/ckeditor5-link/src/linkimageediting.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/**
* @module link/linkimageediting
*/

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
import LinkEditing from './linkediting';

/**
* The link image engine feature.
*
* It accepts the `linkHref="url"` attribute in the model for the {@link module:image/image~Image `<image>`} element
* which allows linking images.
*
* @extends module:core/plugin~Plugin
*/
export default class LinkImageEditing extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ ImageEditing, LinkEditing ];
}

/**
* @inheritDoc
*/
static get pluginName() {
return 'LinkImageEditing';
}

init() {
const editor = this.editor;

editor.model.schema.extend( 'image', { allowAttributes: [ 'linkHref' ] } );

editor.conversion.for( 'upcast' ).add( upcastLink() );
editor.conversion.for( 'downcast' ).add( downcastImageLink() );
}
}

// Returns a converter that consumes the 'href' attribute if a link contains an image.
//
// @private
// @returns {Function}
//
function upcastLink() {
return dispatcher => {
dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
const viewLink = data.viewItem;
const imageInLink = Array.from( viewLink.getChildren() ).find( child => child.name === 'img' );

if ( !imageInLink ) {
return;
}

// There's an image inside an <a> element - we consume it so it won't be picked up by the Link plugin.
const consumableAttributes = { attributes: [ 'href' ] };

// Consume the `href` attribute so the default one will not convert it to $text attribute.
if ( !conversionApi.consumable.consume( viewLink, consumableAttributes ) ) {
// Might be consumed by something else - i.e. other converter with priority=highest - a standard check.
return;
}

const linkHref = viewLink.getAttribute( 'href' );

// Missing the 'href' attribute.
if ( !linkHref ) {
return;
}

// A full definition of the image feature.
// figure > a > img: parent of the link element is an image element.
let modelElement = data.modelCursor.parent;

if ( !modelElement.is( 'image' ) ) {
// a > img: parent of the link is not the image element. We need to convert it manually.
const conversionResult = conversionApi.convertItem( imageInLink, data.modelCursor );

// Set image range as conversion result.
data.modelRange = conversionResult.modelRange;

// Continue conversion where image conversion ends.
data.modelCursor = conversionResult.modelCursor;

modelElement = data.modelCursor.nodeBefore;
}

if ( modelElement && modelElement.is( 'image' ) ) {
// Set the linkHref attribute from link element on model image element.
conversionApi.writer.setAttribute( 'linkHref', linkHref, modelElement );
}
}, { priority: 'high' } );
};
}

// Return a converter that adds the `<a>` element to data.
//
// @private
// @returns {Function}
//
function downcastImageLink() {
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' );

// 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 );
}
} 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 } );

// 2. Insert link inside the associated image.
writer.insert( writer.createPositionAt( viewFigure, 0 ), linkElement );

// 3. Move the image to the link.
writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( linkElement, 0 ) );
}
} );
};
}
39 changes: 39 additions & 0 deletions packages/ckeditor5-link/src/linkimageui.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/**
* @module link/linkimageui
*/

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Image from '@ckeditor/ckeditor5-image/src/image';
import LinkUI from './linkui';
import LinkEditing from './linkediting';

/**
* The link image UI plugin.
*
* TODO: Docs.
*
* @extends module:core/plugin~Plugin
*/
export default class LinkImageUI extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ Image, LinkEditing, LinkUI ];
}

/**
* @inheritDoc
*/
static get pluginName() {
return 'LinkImageUI';
}

init() {
}
}
Loading

0 comments on commit cc0e694

Please sign in to comment.